Compare commits
20 Commits
Steven
...
d44ccc3ace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d44ccc3ace | ||
|
|
e33ea62e35 | ||
|
|
96d95a50fc | ||
|
|
c1ce804d14 | ||
|
|
0aab9a838b | ||
|
|
6822638d47 | ||
|
|
5c1172a912 | ||
|
|
b5373c2d9a | ||
|
|
3ffcc60ee8 | ||
|
|
50e78c84c9 | ||
|
|
8e6c621f7b | ||
|
|
7b8511f080 | ||
|
|
f6fa19d0b2 | ||
|
|
536513ab3f | ||
|
|
240a7eea8b | ||
|
|
22dc3abf65 | ||
|
|
ed1f6fc49d | ||
|
|
218b2e8b29 | ||
|
|
fb1377eb4b | ||
|
|
c07f1f20d5 |
1
App.tsx
@@ -4,6 +4,7 @@
|
|||||||
* Main application component with authentication routing.
|
* Main application component with authentication routing.
|
||||||
* Shows loading screen while restoring auth state.
|
* Shows loading screen while restoring auth state.
|
||||||
*/
|
*/
|
||||||
|
import './src/polyfills';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Buffer } from 'buffer';
|
import { Buffer } from 'buffer';
|
||||||
|
|||||||
6
app.json
@@ -19,14 +19,10 @@
|
|||||||
"bundleIdentifier": "com.sentinel.app"
|
"bundleIdentifier": "com.sentinel.app"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
|
||||||
"backgroundColor": "#459E9E"
|
|
||||||
},
|
|
||||||
"package": "com.sentinel.app"
|
"package": "com.sentinel.app"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png",
|
"favicon": "./assets/icon.png",
|
||||||
"bundler": "metro"
|
"bundler": "metro"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 86 B After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 105 B After Width: | Height: | Size: 70 B |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 52 B After Width: | Height: | Size: 70 B |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 70 B |
15
metro.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
config.resolver.extraNodeModules = {
|
||||||
|
...config.resolver.extraNodeModules,
|
||||||
|
crypto: path.resolve(__dirname, 'src/utils/crypto_polyfill.ts'),
|
||||||
|
stream: require.resolve('readable-stream'),
|
||||||
|
vm: require.resolve('vm-browserify'),
|
||||||
|
async_hooks: path.resolve(__dirname, 'src/utils/async_hooks_mock.ts'),
|
||||||
|
'node:async_hooks': path.resolve(__dirname, 'src/utils/async_hooks_mock.ts'),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
670
package-lock.json
generated
12
package.json
@@ -11,6 +11,10 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~4.0.1",
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
"@expo/vector-icons": "~14.0.4",
|
"@expo/vector-icons": "~14.0.4",
|
||||||
|
"@langchain/core": "^1.1.18",
|
||||||
|
"@langchain/langgraph": "^1.1.3",
|
||||||
|
"@noble/ciphers": "^1.3.0",
|
||||||
|
"@noble/hashes": "^1.8.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^6.6.1",
|
"@react-navigation/bottom-tabs": "^6.6.1",
|
||||||
"@react-navigation/native": "^6.1.18",
|
"@react-navigation/native": "^6.1.18",
|
||||||
@@ -20,6 +24,7 @@
|
|||||||
"expo": "~52.0.0",
|
"expo": "~52.0.0",
|
||||||
"expo-asset": "~11.0.5",
|
"expo-asset": "~11.0.5",
|
||||||
"expo-constants": "~17.0.8",
|
"expo-constants": "~17.0.8",
|
||||||
|
"expo-crypto": "~14.0.2",
|
||||||
"expo-font": "~13.0.4",
|
"expo-font": "~13.0.4",
|
||||||
"expo-haptics": "~14.0.0",
|
"expo-haptics": "~14.0.0",
|
||||||
"expo-image-picker": "^17.0.10",
|
"expo-image-picker": "^17.0.10",
|
||||||
@@ -29,11 +34,14 @@
|
|||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "^0.76.9",
|
"react-native": "^0.76.9",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"react-native-view-shot": "^3.8.0",
|
|
||||||
"react-native-reanimated": "~3.16.1",
|
"react-native-reanimated": "~3.16.1",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
"react-native-web": "~0.19.13"
|
"react-native-svg": "^15.15.2",
|
||||||
|
"react-native-view-shot": "^3.8.0",
|
||||||
|
"react-native-web": "~0.19.13",
|
||||||
|
"readable-stream": "^4.7.0",
|
||||||
|
"vm-browserify": "^1.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function BiometricModal({
|
|||||||
if (visible) {
|
if (visible) {
|
||||||
setIsScanning(false);
|
setIsScanning(false);
|
||||||
scanAnimation.setValue(0);
|
scanAnimation.setValue(0);
|
||||||
|
|
||||||
// Pulse animation
|
// Pulse animation
|
||||||
Animated.loop(
|
Animated.loop(
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
@@ -57,32 +57,30 @@ export default function BiometricModal({
|
|||||||
|
|
||||||
const handleScan = () => {
|
const handleScan = () => {
|
||||||
setIsScanning(true);
|
setIsScanning(true);
|
||||||
|
|
||||||
Animated.loop(
|
Animated.loop(
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(scanAnimation, {
|
Animated.timing(scanAnimation, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 800,
|
duration: 400,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
Animated.timing(scanAnimation, {
|
Animated.timing(scanAnimation, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
duration: 800,
|
duration: 400,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
{ iterations: 2 }
|
{ iterations: 1 }
|
||||||
).start(() => {
|
).start(() => {
|
||||||
setTimeout(() => {
|
onSuccess();
|
||||||
onSuccess();
|
|
||||||
}, 300);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const backgroundColor = isDark ? colors.vault.cardBackground : colors.white;
|
const backgroundColor = isDark ? colors.vault.cardBackground : colors.white;
|
||||||
const textColor = isDark ? colors.vault.text : colors.nautical.navy;
|
const textColor = isDark ? colors.vault.text : colors.nautical.navy;
|
||||||
const accentColor = isDark ? colors.vault.primary : colors.nautical.teal;
|
const accentColor = isDark ? colors.vault.primary : colors.nautical.teal;
|
||||||
const accentGradient: [string, string] = isDark
|
const accentGradient: [string, string] = isDark
|
||||||
? [colors.vault.primary, colors.vault.secondary]
|
? [colors.vault.primary, colors.vault.secondary]
|
||||||
: [colors.nautical.teal, colors.nautical.seafoam];
|
: [colors.nautical.teal, colors.nautical.seafoam];
|
||||||
|
|
||||||
@@ -97,10 +95,10 @@ export default function BiometricModal({
|
|||||||
<View style={[styles.container, { backgroundColor }, shadows.medium]}>
|
<View style={[styles.container, { backgroundColor }, shadows.medium]}>
|
||||||
{/* Ship wheel watermark */}
|
{/* Ship wheel watermark */}
|
||||||
<View style={styles.watermark}>
|
<View style={styles.watermark}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="ship-wheel"
|
name="ship-wheel"
|
||||||
size={150}
|
size={150}
|
||||||
color={isDark ? colors.vault.primary : colors.nautical.lightMint}
|
color={isDark ? colors.vault.primary : colors.nautical.lightMint}
|
||||||
style={{ opacity: 0.15 }}
|
style={{ opacity: 0.15 }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
@@ -109,7 +107,7 @@ export default function BiometricModal({
|
|||||||
<Text style={[styles.message, { color: isDark ? colors.vault.textSecondary : colors.nautical.sage }]}>
|
<Text style={[styles.message, { color: isDark ? colors.vault.textSecondary : colors.nautical.sage }]}>
|
||||||
{message}
|
{message}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.fingerprintButton}
|
style={styles.fingerprintButton}
|
||||||
onPress={handleScan}
|
onPress={handleScan}
|
||||||
@@ -147,10 +145,10 @@ export default function BiometricModal({
|
|||||||
colors={accentGradient}
|
colors={accentGradient}
|
||||||
style={styles.fingerprintGradient}
|
style={styles.fingerprintGradient}
|
||||||
>
|
>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isScanning ? "finger-print" : "finger-print-outline"}
|
name={isScanning ? "finger-print" : "finger-print-outline"}
|
||||||
size={48}
|
size={48}
|
||||||
color="#fff"
|
color="#fff"
|
||||||
/>
|
/>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|||||||
85
src/components/puppet/FlowPuppetSlot.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* FlowPuppetSlot - Slot for FlowScreen to show interactive AI puppet.
|
||||||
|
* Composes PuppetView and optional action buttons; does not depend on FlowScreen logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { PuppetView } from './PuppetView';
|
||||||
|
import type { FlowPuppetSlotProps, PuppetAction } from './types';
|
||||||
|
import { colors } from '../../theme/colors';
|
||||||
|
import { borderRadius, spacing } from '../../theme/colors';
|
||||||
|
|
||||||
|
const ACTIONS: PuppetAction[] = ['smile', 'jump', 'shake'];
|
||||||
|
|
||||||
|
export function FlowPuppetSlot({
|
||||||
|
currentAction,
|
||||||
|
isTalking,
|
||||||
|
onAction,
|
||||||
|
showActionButtons = true,
|
||||||
|
}: FlowPuppetSlotProps) {
|
||||||
|
const [localAction, setLocalAction] = useState<PuppetAction>(currentAction);
|
||||||
|
|
||||||
|
const effectiveAction = currentAction !== 'idle' ? currentAction : localAction;
|
||||||
|
|
||||||
|
const handleAction = useCallback(
|
||||||
|
(action: PuppetAction) => {
|
||||||
|
setLocalAction(action);
|
||||||
|
onAction?.(action);
|
||||||
|
if (['smile', 'wave', 'nod', 'shake', 'jump'].includes(action)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setLocalAction((prev) => (prev === action ? 'idle' : prev));
|
||||||
|
onAction?.('idle');
|
||||||
|
}, 2600);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<PuppetView action={effectiveAction} isTalking={isTalking} />
|
||||||
|
{showActionButtons && (
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{ACTIONS.map((act) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={act}
|
||||||
|
style={styles.actionBtn}
|
||||||
|
onPress={() => handleAction(act)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.actionLabel}>{act}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionBtn: {
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
backgroundColor: colors.flow.cardBackground,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.flow.cardBorder,
|
||||||
|
},
|
||||||
|
actionLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.flow.primary,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
},
|
||||||
|
});
|
||||||
340
src/components/puppet/PuppetView.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* PuppetView - Interactive blue spirit avatar (React Native).
|
||||||
|
* Port of airi---interactive-ai-puppet Puppet with same actions:
|
||||||
|
* idle, wave, nod, shake, jump, think; mouth reflects isTalking.
|
||||||
|
* Code isolated so FlowScreen stays unchanged except composition.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { View, StyleSheet, Animated, Easing } from 'react-native';
|
||||||
|
import { PuppetViewProps } from './types';
|
||||||
|
|
||||||
|
const PUPPET_SIZE = 160;
|
||||||
|
|
||||||
|
export function PuppetView({ action, isTalking }: PuppetViewProps) {
|
||||||
|
const floatAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const bounceAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const shakeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const thinkScale = useRef(new Animated.Value(1)).current;
|
||||||
|
const thinkOpacity = useRef(new Animated.Value(1)).current;
|
||||||
|
const smileScale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
// Idle: gentle float
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'idle') return;
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(floatAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 2000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(floatAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 2000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, floatAnim]);
|
||||||
|
|
||||||
|
// Smile: exaggerated smile scale pulse
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'smile') {
|
||||||
|
smileScale.setValue(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(smileScale, {
|
||||||
|
toValue: 1.12,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.out(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(smileScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.in(Easing.ease),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
{ iterations: 3 }
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, smileScale]);
|
||||||
|
|
||||||
|
// Wave / Jump: bounce
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'wave' && action !== 'jump') return;
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(bounceAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.out(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(bounceAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.in(Easing.ease),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, bounceAnim]);
|
||||||
|
|
||||||
|
// Shake: wiggle
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'shake') return;
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(shakeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 150,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 150,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, shakeAnim]);
|
||||||
|
|
||||||
|
// Think: scale + opacity pulse
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'think') {
|
||||||
|
thinkScale.setValue(1);
|
||||||
|
thinkOpacity.setValue(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(thinkScale, {
|
||||||
|
toValue: 0.92,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(thinkOpacity, {
|
||||||
|
toValue: 0.85,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(thinkScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(thinkOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, thinkScale, thinkOpacity]);
|
||||||
|
|
||||||
|
const floatY = floatAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, -8],
|
||||||
|
});
|
||||||
|
const bounceY = bounceAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, -20],
|
||||||
|
});
|
||||||
|
const shakeRotate = shakeAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['0deg', '8deg'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBounce = action === 'wave' || action === 'jump';
|
||||||
|
const isShake = action === 'shake';
|
||||||
|
const isSmile = action === 'smile';
|
||||||
|
|
||||||
|
const mouthStyle = isTalking
|
||||||
|
? [styles.mouth, styles.mouthOpen]
|
||||||
|
: isSmile
|
||||||
|
? [styles.mouth, styles.mouthBigSmile]
|
||||||
|
: [styles.mouth, styles.mouthSmile];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
action === 'idle' && {
|
||||||
|
transform: [{ translateY: floatY }],
|
||||||
|
},
|
||||||
|
isBounce && {
|
||||||
|
transform: [{ translateY: bounceY }],
|
||||||
|
},
|
||||||
|
isShake && {
|
||||||
|
transform: [{ rotate: shakeRotate }],
|
||||||
|
},
|
||||||
|
action === 'think' && {
|
||||||
|
transform: [{ scale: thinkScale }],
|
||||||
|
opacity: thinkOpacity,
|
||||||
|
},
|
||||||
|
isSmile && {
|
||||||
|
transform: [{ scale: smileScale }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Aura glow */}
|
||||||
|
<View style={styles.aura} />
|
||||||
|
{/* Body (droplet-like rounded rect) */}
|
||||||
|
<View style={styles.body}>
|
||||||
|
{/* Gloss */}
|
||||||
|
<View style={styles.gloss} />
|
||||||
|
{/* Cheeks */}
|
||||||
|
<View style={[styles.cheek, styles.cheekLeft]} />
|
||||||
|
<View style={[styles.cheek, styles.cheekRight]} />
|
||||||
|
{/* Eyes */}
|
||||||
|
<View style={styles.eyes}>
|
||||||
|
<View style={[styles.eye, styles.eyeLeft]}>
|
||||||
|
<View style={styles.eyeSparkle} />
|
||||||
|
</View>
|
||||||
|
<View style={[styles.eye, styles.eyeRight]}>
|
||||||
|
<View style={styles.eyeSparkle} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* Mouth - default smile; open when talking; big smile when smile action */}
|
||||||
|
<View style={mouthStyle} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BODY_SIZE = PUPPET_SIZE * 0.9;
|
||||||
|
const EYE_SIZE = 10;
|
||||||
|
const EYE_OFFSET_X = 18;
|
||||||
|
const EYE_OFFSET_Y = -8;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: PUPPET_SIZE,
|
||||||
|
height: PUPPET_SIZE,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
aura: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: PUPPET_SIZE + 40,
|
||||||
|
height: PUPPET_SIZE + 40,
|
||||||
|
borderRadius: (PUPPET_SIZE + 40) / 2,
|
||||||
|
backgroundColor: 'rgba(14, 165, 233, 0.15)',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
width: BODY_SIZE,
|
||||||
|
height: BODY_SIZE,
|
||||||
|
borderRadius: BODY_SIZE / 2,
|
||||||
|
backgroundColor: '#0ea5e9',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#0c4a6e',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
gloss: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: BODY_SIZE * 0.12,
|
||||||
|
left: BODY_SIZE * 0.2,
|
||||||
|
right: BODY_SIZE * 0.2,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.35)',
|
||||||
|
},
|
||||||
|
cheek: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.35)',
|
||||||
|
},
|
||||||
|
cheekLeft: {
|
||||||
|
left: BODY_SIZE * 0.15,
|
||||||
|
top: BODY_SIZE * 0.42,
|
||||||
|
},
|
||||||
|
cheekRight: {
|
||||||
|
right: BODY_SIZE * 0.15,
|
||||||
|
top: BODY_SIZE * 0.42,
|
||||||
|
},
|
||||||
|
eyes: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
top: BODY_SIZE * 0.34,
|
||||||
|
},
|
||||||
|
eye: {
|
||||||
|
width: EYE_SIZE,
|
||||||
|
height: EYE_SIZE,
|
||||||
|
borderRadius: EYE_SIZE / 2,
|
||||||
|
backgroundColor: '#0c4a6e',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
eyeLeft: { marginRight: EYE_OFFSET_X },
|
||||||
|
eyeRight: { marginLeft: EYE_OFFSET_X },
|
||||||
|
eyeSparkle: {
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 1,
|
||||||
|
left: 2,
|
||||||
|
},
|
||||||
|
mouth: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: BODY_SIZE * 0.52,
|
||||||
|
backgroundColor: '#0c4a6e',
|
||||||
|
},
|
||||||
|
mouthSmile: {
|
||||||
|
width: 22,
|
||||||
|
height: 6,
|
||||||
|
borderBottomLeftRadius: 11,
|
||||||
|
borderBottomRightRadius: 11,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
},
|
||||||
|
mouthOpen: {
|
||||||
|
width: 18,
|
||||||
|
height: 6,
|
||||||
|
top: BODY_SIZE * 0.51,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: 'rgba(12, 74, 110, 0.9)',
|
||||||
|
},
|
||||||
|
mouthBigSmile: {
|
||||||
|
width: 32,
|
||||||
|
height: 10,
|
||||||
|
top: BODY_SIZE * 0.51,
|
||||||
|
borderBottomLeftRadius: 16,
|
||||||
|
borderBottomRightRadius: 16,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
3
src/components/puppet/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { PuppetView } from './PuppetView';
|
||||||
|
export { FlowPuppetSlot } from './FlowPuppetSlot';
|
||||||
|
export type { PuppetAction, PuppetState, PuppetViewProps, FlowPuppetSlotProps } from './types';
|
||||||
28
src/components/puppet/types.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Puppet types - compatible with airi interactive AI puppet semantics.
|
||||||
|
* Used for FlowScreen multimodal avatar (action + talking state).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PuppetAction = 'idle' | 'wave' | 'nod' | 'shake' | 'jump' | 'think' | 'talk' | 'smile';
|
||||||
|
|
||||||
|
export interface PuppetState {
|
||||||
|
currentAction: PuppetAction;
|
||||||
|
isTalking: boolean;
|
||||||
|
isThinking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuppetViewProps {
|
||||||
|
action: PuppetAction;
|
||||||
|
isTalking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowPuppetSlotProps {
|
||||||
|
/** Current action (idle, wave, nod, shake, jump, think). */
|
||||||
|
currentAction: PuppetAction;
|
||||||
|
/** True when AI is "speaking" (e.g. streaming or responding). */
|
||||||
|
isTalking: boolean;
|
||||||
|
/** Optional: allow parent to set action (e.g. from AI tool call). */
|
||||||
|
onAction?: (action: PuppetAction) => void;
|
||||||
|
/** Show quick action buttons (wave, jump, shake) for interactivity. */
|
||||||
|
showActionButtons?: boolean;
|
||||||
|
}
|
||||||
@@ -51,11 +51,13 @@ export const API_ENDPOINTS = {
|
|||||||
CREATE: '/assets/create',
|
CREATE: '/assets/create',
|
||||||
CLAIM: '/assets/claim',
|
CLAIM: '/assets/claim',
|
||||||
ASSIGN: '/assets/assign',
|
ASSIGN: '/assets/assign',
|
||||||
|
DELETE: '/assets/delete',
|
||||||
},
|
},
|
||||||
|
|
||||||
// AI Services
|
// AI Services
|
||||||
AI: {
|
AI: {
|
||||||
PROXY: '/ai/proxy',
|
PROXY: '/ai/proxy',
|
||||||
|
GET_ROLES: '/get_ai_roles',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Admin Operations
|
// Admin Operations
|
||||||
@@ -64,6 +66,48 @@ export const API_ENDPOINTS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Vault storage (user-isolated, multi-account)
|
||||||
|
// =============================================================================
|
||||||
|
// - AsyncStorage keys for vault state (S0 share, initialized flag, mnemonic part backup).
|
||||||
|
// - User-scoped: each account has its own keys so vault/mnemonic state is isolated.
|
||||||
|
// - Store: use getVaultStorageKeys(userId) and write to INITIALIZED / SHARE_DEVICE / MNEMONIC_PART_LOCAL.
|
||||||
|
// - Clear: use same keys in multiRemove (e.g. MeScreen Reset Vault State).
|
||||||
|
// - Multi-account: same device, multiple users → each has independent vault (no cross-user leakage).
|
||||||
|
|
||||||
|
const VAULT_KEY_PREFIX = 'sentinel_vault';
|
||||||
|
|
||||||
|
/** Base key names (for reference). Prefer getVaultStorageKeys(userId) for all reads/writes. */
|
||||||
|
export const VAULT_STORAGE_KEYS = {
|
||||||
|
INITIALIZED: 'sentinel_vault_initialized',
|
||||||
|
SHARE_DEVICE: 'sentinel_vault_s0',
|
||||||
|
MNEMONIC_PART_LOCAL: 'sentinel_mnemonic_part_local',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns vault storage keys for the given user (user isolation).
|
||||||
|
* - Use for: reading/writing S0, mnemonic part backup, clearing on Reset Vault State.
|
||||||
|
* - userId null → guest namespace (_guest). userId set → per-user namespace (_u{userId}).
|
||||||
|
*/
|
||||||
|
export function getVaultStorageKeys(userId: number | string | null): {
|
||||||
|
INITIALIZED: string;
|
||||||
|
SHARE_DEVICE: string;
|
||||||
|
MNEMONIC_PART_LOCAL: string;
|
||||||
|
AES_KEY: string;
|
||||||
|
SHARE_SERVER: string;
|
||||||
|
SHARE_HEIR: string;
|
||||||
|
} {
|
||||||
|
const suffix = userId != null ? `_u${userId}` : '_guest';
|
||||||
|
return {
|
||||||
|
INITIALIZED: `${VAULT_KEY_PREFIX}_initialized${suffix}`,
|
||||||
|
SHARE_DEVICE: `${VAULT_KEY_PREFIX}_s0${suffix}`,
|
||||||
|
MNEMONIC_PART_LOCAL: `sentinel_mnemonic_part_local${suffix}`,
|
||||||
|
AES_KEY: `sentinel_aes_key${suffix}`,
|
||||||
|
SHARE_SERVER: `sentinel_share_server${suffix}`,
|
||||||
|
SHARE_HEIR: `sentinel_share_heir${suffix}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -138,6 +182,44 @@ export const AI_CONFIG = {
|
|||||||
* Mock response delay in milliseconds (for NO_BACKEND_MODE)
|
* Mock response delay in milliseconds (for NO_BACKEND_MODE)
|
||||||
*/
|
*/
|
||||||
MOCK_RESPONSE_DELAY: 500,
|
MOCK_RESPONSE_DELAY: 500,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI Roles configuration
|
||||||
|
*/
|
||||||
|
ROLES: [
|
||||||
|
{
|
||||||
|
id: 'reflective',
|
||||||
|
name: 'Reflective Assistant',
|
||||||
|
description: 'Helps you dive deep into your thoughts and feelings through meaningful reflection.',
|
||||||
|
systemPrompt: 'You are a helpful journal assistant. Help the user reflect on their thoughts and feelings.',
|
||||||
|
icon: 'journal-outline',
|
||||||
|
iconFamily: 'Ionicons',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'creative',
|
||||||
|
name: 'Creative Spark',
|
||||||
|
description: 'A partner for brainstorming, creative writing, and exploring new ideas.',
|
||||||
|
systemPrompt: 'You are a creative brainstorming partner. Help the user explore new ideas, write stories, or look at things from a fresh perspective.',
|
||||||
|
icon: 'bulb-outline',
|
||||||
|
iconFamily: 'Ionicons',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'planner',
|
||||||
|
name: 'Action Planner',
|
||||||
|
description: 'Focused on turning thoughts into actionable plans and organized goals.',
|
||||||
|
systemPrompt: 'You are a productivity coach. Help the user break down their thoughts into actionable steps and clear goals.',
|
||||||
|
icon: 'list-outline',
|
||||||
|
iconFamily: 'Ionicons',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'empathetic',
|
||||||
|
name: 'Empathetic Guide',
|
||||||
|
description: 'Provides a safe, non-judgmental space for emotional support and empathy.',
|
||||||
|
systemPrompt: 'You are a supportive and empathetic friend. Listen to the user\'s concerns and provide emotional support without judgment.',
|
||||||
|
icon: 'heart-outline',
|
||||||
|
iconFamily: 'Ionicons',
|
||||||
|
},
|
||||||
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { User, LoginRequest, RegisterRequest } from '../types';
|
import { User, LoginRequest, RegisterRequest, AIRole } from '../types';
|
||||||
import { authService } from '../services/auth.service';
|
import { authService } from '../services/auth.service';
|
||||||
|
import { aiService } from '../services/ai.service';
|
||||||
|
import { storageService } from '../services/storage.service';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Type Definitions
|
// Type Definitions
|
||||||
@@ -17,11 +19,13 @@ import { authService } from '../services/auth.service';
|
|||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
aiRoles: AIRole[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
signIn: (credentials: LoginRequest) => Promise<void>;
|
signIn: (credentials: LoginRequest) => Promise<void>;
|
||||||
signUp: (data: RegisterRequest) => Promise<void>;
|
signUp: (data: RegisterRequest) => Promise<void>;
|
||||||
signOut: () => void;
|
signOut: () => void;
|
||||||
|
refreshAIRoles: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage keys
|
// Storage keys
|
||||||
@@ -43,6 +47,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [aiRoles, setAIRoles] = useState<AIRole[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isInitializing, setIsInitializing] = useState(true);
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
|
|
||||||
@@ -65,6 +70,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setToken(storedToken);
|
setToken(storedToken);
|
||||||
setUser(JSON.parse(storedUser));
|
setUser(JSON.parse(storedUser));
|
||||||
console.log('[Auth] Restored session for user:', JSON.parse(storedUser).username);
|
console.log('[Auth] Restored session for user:', JSON.parse(storedUser).username);
|
||||||
|
// Fetch AI roles after restoring session
|
||||||
|
fetchAIRoles(storedToken);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Auth] Failed to load stored auth:', error);
|
console.error('[Auth] Failed to load stored auth:', error);
|
||||||
@@ -73,6 +80,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch AI roles from API
|
||||||
|
*/
|
||||||
|
const fetchAIRoles = async (authToken: string) => {
|
||||||
|
console.log('[Auth] Fetching AI roles with token:', authToken ? `${authToken.substring(0, 10)}...` : 'MISSING');
|
||||||
|
try {
|
||||||
|
const roles = await aiService.getAIRoles(authToken);
|
||||||
|
setAIRoles(roles);
|
||||||
|
console.log('[Auth] AI roles fetched successfully:', roles.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Failed to fetch AI roles:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual refresh of AI roles
|
||||||
|
*/
|
||||||
|
const refreshAIRoles = async () => {
|
||||||
|
if (token) {
|
||||||
|
await fetchAIRoles(token);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save authentication to AsyncStorage
|
* Save authentication to AsyncStorage
|
||||||
*/
|
*/
|
||||||
@@ -113,6 +143,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setToken(response.access_token);
|
setToken(response.access_token);
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
await saveAuth(response.access_token, response.user);
|
await saveAuth(response.access_token, response.user);
|
||||||
|
// Fetch AI roles immediately after login
|
||||||
|
await fetchAIRoles(response.access_token);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,24 +169,30 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign out and clear stored auth
|
* Sign out and clear stored auth and session data
|
||||||
*/
|
*/
|
||||||
const signOut = () => {
|
const signOut = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setAIRoles([]);
|
||||||
clearAuth();
|
clearAuth();
|
||||||
|
|
||||||
|
|
||||||
|
//storageService.clearAllData();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
token,
|
token,
|
||||||
isLoading,
|
aiRoles,
|
||||||
|
isLoading,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
signIn,
|
signIn,
|
||||||
signUp,
|
signUp,
|
||||||
signOut
|
signOut,
|
||||||
|
refreshAIRoles
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
6
src/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* React hooks for Sentinel
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useVaultAssets } from './useVaultAssets';
|
||||||
|
export type { CreateAssetResult, UseVaultAssetsReturn } from './useVaultAssets';
|
||||||
278
src/hooks/useVaultAssets.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* useVaultAssets: Encapsulates /assets/get and /assets/create for VaultScreen.
|
||||||
|
* - Fetches assets when vault is unlocked and token exists.
|
||||||
|
* - Exposes createAsset with 401/network error handling and list refresh on success.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import * as bip39 from 'bip39';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { assetsService } from '../services/assets.service';
|
||||||
|
import { getVaultStorageKeys, DEBUG_MODE } from '../config';
|
||||||
|
import { SentinelVault } from '../utils/crypto_core';
|
||||||
|
import { storageService } from '../services/storage.service';
|
||||||
|
import {
|
||||||
|
initialVaultAssets,
|
||||||
|
mapApiAssetsToVaultAssets,
|
||||||
|
type ApiAsset,
|
||||||
|
} from '../utils/vaultAssets';
|
||||||
|
import type { VaultAsset } from '../types';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CreateAssetResult {
|
||||||
|
success: boolean;
|
||||||
|
isUnauthorized?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseVaultAssetsReturn {
|
||||||
|
/** Current list (mock until API succeeds) */
|
||||||
|
assets: VaultAsset[];
|
||||||
|
/** Replace list (e.g. after external refresh) */
|
||||||
|
setAssets: React.Dispatch<React.SetStateAction<VaultAsset[]>>;
|
||||||
|
/** Refetch from GET /assets/get */
|
||||||
|
refreshAssets: () => Promise<void>;
|
||||||
|
/** Create asset via POST /assets/create; on success refreshes list */
|
||||||
|
createAsset: (params: { title: string; content: string }) => Promise<CreateAssetResult>;
|
||||||
|
/** Delete asset via POST /assets/delete; on success refreshes list */
|
||||||
|
deleteAsset: (assetId: number) => Promise<CreateAssetResult>;
|
||||||
|
/** Assign asset to heir via POST /assets/assign */
|
||||||
|
assignAsset: (assetId: number, heirEmail: string) => Promise<CreateAssetResult>;
|
||||||
|
/** True while create request is in flight */
|
||||||
|
isSealing: boolean;
|
||||||
|
/** Error message from last create failure (non-401) */
|
||||||
|
createError: string | null;
|
||||||
|
/** Clear createError */
|
||||||
|
clearCreateError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Hook
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vault assets list + create. Fetches on unlock when token exists; keeps mock on error.
|
||||||
|
*/
|
||||||
|
export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
|
||||||
|
const { user, token, signOut } = useAuth();
|
||||||
|
const [assets, setAssets] = useState<VaultAsset[]>(initialVaultAssets);
|
||||||
|
const [isSealing, setIsSealing] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshAssets = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const list = await assetsService.getMyAssets(token);
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
|
||||||
|
if (/Could not validate credentials/i.test(rawMessage)) {
|
||||||
|
signOut();
|
||||||
|
}
|
||||||
|
// Keep current assets (mock or previous fetch)
|
||||||
|
}
|
||||||
|
}, [token, signOut]);
|
||||||
|
|
||||||
|
// Fetch list when unlocked and token exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUnlocked || !token) return;
|
||||||
|
let cancelled = false;
|
||||||
|
assetsService
|
||||||
|
.getMyAssets(token)
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled && Array.isArray(list)) {
|
||||||
|
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
|
||||||
|
if (/Could not validate credentials/i.test(rawMessage)) {
|
||||||
|
signOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep initial (mock) assets
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isUnlocked, token]);
|
||||||
|
|
||||||
|
const createAsset = useCallback(
|
||||||
|
async ({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}): Promise<CreateAssetResult> => {
|
||||||
|
if (!token) {
|
||||||
|
return { success: false, error: 'Not logged in.' };
|
||||||
|
}
|
||||||
|
setIsSealing(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
const vaultKeys = getVaultStorageKeys(user?.id ?? null);
|
||||||
|
const [s1Str, aesKeyHex, s0Str, s2Str] = await Promise.all([
|
||||||
|
AsyncStorage.getItem(vaultKeys.SHARE_SERVER),
|
||||||
|
AsyncStorage.getItem(vaultKeys.AES_KEY),
|
||||||
|
AsyncStorage.getItem(vaultKeys.SHARE_DEVICE),
|
||||||
|
AsyncStorage.getItem(vaultKeys.SHARE_HEIR),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!s1Str || !aesKeyHex) {
|
||||||
|
throw new Error('Vault keys missing. Please re-unlock your vault.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const vault = new SentinelVault();
|
||||||
|
const aesKey = Buffer.from(aesKeyHex, 'hex');
|
||||||
|
const encryptedBuffer = vault.encryptData(aesKey, content.trim());
|
||||||
|
const content_inner_encrypted = encryptedBuffer.toString('hex');
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
console.log('[DEBUG] Crypto Data during Asset Creation:');
|
||||||
|
console.log(' s0 (Device):', s0Str);
|
||||||
|
console.log(' s1 (Server):', s1Str);
|
||||||
|
console.log(' s2 (Heir): ', s2Str);
|
||||||
|
console.log(' AES Key: ', aesKeyHex);
|
||||||
|
console.log(' Encrypted: ', content_inner_encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAsset = await assetsService.createAsset(
|
||||||
|
{
|
||||||
|
title: title.trim(),
|
||||||
|
private_key_shard: s1Str,
|
||||||
|
content_inner_encrypted,
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
// Backup plaintext content locally
|
||||||
|
if (createdAsset && createdAsset.id && user?.id) {
|
||||||
|
await storageService.saveAssetBackup(createdAsset.id, content, user.id);
|
||||||
|
}
|
||||||
|
await refreshAssets();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
err && typeof err === 'object' && 'status' in err
|
||||||
|
? (err as { status?: number }).status
|
||||||
|
: undefined;
|
||||||
|
const rawMessage =
|
||||||
|
err instanceof Error ? err.message : String(err ?? 'Failed to create.');
|
||||||
|
const isUnauthorized =
|
||||||
|
status === 401 || /401|Unauthorized/i.test(rawMessage);
|
||||||
|
|
||||||
|
if (isUnauthorized) {
|
||||||
|
signOut();
|
||||||
|
return { success: false, isUnauthorized: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
|
||||||
|
? 'Network error. Please check that the backend is running and reachable (see API_BASE_URL in config).'
|
||||||
|
: rawMessage;
|
||||||
|
setCreateError(friendlyMessage);
|
||||||
|
return { success: false, error: friendlyMessage };
|
||||||
|
} finally {
|
||||||
|
setIsSealing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token, user, refreshAssets, signOut]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteAsset = useCallback(
|
||||||
|
async (assetId: number): Promise<CreateAssetResult> => {
|
||||||
|
if (!token) {
|
||||||
|
return { success: false, error: 'Not logged in.' };
|
||||||
|
}
|
||||||
|
setIsSealing(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
await assetsService.deleteAsset(assetId, token);
|
||||||
|
await refreshAssets();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
err && typeof err === 'object' && 'status' in err
|
||||||
|
? (err as { status?: number }).status
|
||||||
|
: undefined;
|
||||||
|
const rawMessage =
|
||||||
|
err instanceof Error ? err.message : String(err ?? 'Failed to delete.');
|
||||||
|
const isUnauthorized =
|
||||||
|
status === 401 || /401|Unauthorized/i.test(rawMessage);
|
||||||
|
|
||||||
|
if (isUnauthorized) {
|
||||||
|
signOut();
|
||||||
|
return { success: false, isUnauthorized: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
|
||||||
|
? 'Network error. Please check that the backend is running and reachable.'
|
||||||
|
: rawMessage;
|
||||||
|
setCreateError(friendlyMessage);
|
||||||
|
return { success: false, error: friendlyMessage };
|
||||||
|
} finally {
|
||||||
|
setIsSealing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token, refreshAssets, signOut]
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignAsset = useCallback(
|
||||||
|
async (assetId: number, heirEmail: string): Promise<CreateAssetResult> => {
|
||||||
|
if (!token) {
|
||||||
|
return { success: false, error: 'Not logged in.' };
|
||||||
|
}
|
||||||
|
setIsSealing(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
await assetsService.assignAsset({ asset_id: assetId, heir_email: heirEmail }, token);
|
||||||
|
await refreshAssets();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
err && typeof err === 'object' && 'status' in err
|
||||||
|
? (err as { status?: number }).status
|
||||||
|
: undefined;
|
||||||
|
const rawMessage =
|
||||||
|
err instanceof Error ? err.message : String(err ?? 'Failed to assign.');
|
||||||
|
const isUnauthorized =
|
||||||
|
status === 401 || /401|Unauthorized/i.test(rawMessage);
|
||||||
|
|
||||||
|
if (isUnauthorized) {
|
||||||
|
signOut();
|
||||||
|
return { success: false, isUnauthorized: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
|
||||||
|
? 'Network error. Please check that the backend is running and reachable.'
|
||||||
|
: rawMessage;
|
||||||
|
setCreateError(friendlyMessage);
|
||||||
|
return { success: false, error: friendlyMessage };
|
||||||
|
} finally {
|
||||||
|
setIsSealing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token, signOut]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearCreateError = useCallback(() => setCreateError(null), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assets,
|
||||||
|
setAssets,
|
||||||
|
refreshAssets,
|
||||||
|
createAsset,
|
||||||
|
deleteAsset,
|
||||||
|
assignAsset,
|
||||||
|
isSealing,
|
||||||
|
createError,
|
||||||
|
clearCreateError,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/polyfills.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Polyfills that must run before any other app code (including LangChain/LangGraph).
|
||||||
|
* This file is imported as the very first line in App.tsx so that ReadableStream
|
||||||
|
* and crypto.getRandomValues exist before @langchain/core / uuid are loaded.
|
||||||
|
*/
|
||||||
|
import 'web-streams-polyfill';
|
||||||
|
|
||||||
|
// Ensure globalThis has ReadableStream (main polyfill may not patch in RN/Metro)
|
||||||
|
const g = typeof globalThis !== 'undefined' ? globalThis : (typeof global !== 'undefined' ? global : (typeof self !== 'undefined' ? self : {}));
|
||||||
|
if (typeof (g as any).ReadableStream === 'undefined') {
|
||||||
|
const ponyfill = require('web-streams-polyfill/dist/ponyfill.js');
|
||||||
|
(g as any).ReadableStream = ponyfill.ReadableStream;
|
||||||
|
(g as any).WritableStream = ponyfill.WritableStream;
|
||||||
|
(g as any).TransformStream = ponyfill.TransformStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polyfill crypto.getRandomValues for React Native/Expo (required by uuid, LangChain, etc.)
|
||||||
|
if (typeof g !== 'undefined') {
|
||||||
|
const cryptoObj = (g as any).crypto;
|
||||||
|
if (!cryptoObj || typeof (cryptoObj.getRandomValues) !== 'function') {
|
||||||
|
try {
|
||||||
|
const ExpoCrypto = require('expo-crypto');
|
||||||
|
const getRandomValues = (array: ArrayBufferView): ArrayBufferView => {
|
||||||
|
ExpoCrypto.getRandomValues(array);
|
||||||
|
return array;
|
||||||
|
};
|
||||||
|
if (!(g as any).crypto) (g as any).crypto = {};
|
||||||
|
(g as any).crypto.getRandomValues = getRandomValues;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[polyfills] crypto.getRandomValues polyfill failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polyfill AbortSignal.prototype.throwIfAborted (required by fetch/LangChain in RN; not present in older runtimes)
|
||||||
|
const AbortSignalGlobal = (g as any).AbortSignal;
|
||||||
|
if (typeof AbortSignalGlobal === 'function' && AbortSignalGlobal.prototype && typeof AbortSignalGlobal.prototype.throwIfAborted !== 'function') {
|
||||||
|
AbortSignalGlobal.prototype.throwIfAborted = function (this: AbortSignal) {
|
||||||
|
if (this.aborted) {
|
||||||
|
const e = new Error('Aborted');
|
||||||
|
e.name = 'AbortError';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
|
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
|
||||||
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Heir, HeirStatus, PaymentStrategy } from '../types';
|
import { Heir, HeirStatus, PaymentStrategy } from '../types';
|
||||||
import HeritageScreen from './HeritageScreen';
|
import HeritageScreen from './HeritageScreen';
|
||||||
|
import { getVaultStorageKeys } from '../config';
|
||||||
|
|
||||||
// Mock heirs data
|
// Mock heirs data
|
||||||
const initialHeirs: Heir[] = [
|
const initialHeirs: Heir[] = [
|
||||||
@@ -220,7 +222,8 @@ export default function MeScreen() {
|
|||||||
const [showHeritageModal, setShowHeritageModal] = useState(false);
|
const [showHeritageModal, setShowHeritageModal] = useState(false);
|
||||||
const [showThemeModal, setShowThemeModal] = useState(false);
|
const [showThemeModal, setShowThemeModal] = useState(false);
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
|
const [showSignOutModal, setShowSignOutModal] = useState(false);
|
||||||
|
|
||||||
// Heritage / Fleet Legacy states
|
// Heritage / Fleet Legacy states
|
||||||
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
|
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
|
||||||
const [showAddHeirModal, setShowAddHeirModal] = useState(false);
|
const [showAddHeirModal, setShowAddHeirModal] = useState(false);
|
||||||
@@ -245,6 +248,7 @@ export default function MeScreen() {
|
|||||||
});
|
});
|
||||||
const [sanctumArchive, setSanctumArchive] = useState<'off' | 'standard' | 'strict'>('standard');
|
const [sanctumArchive, setSanctumArchive] = useState<'off' | 'standard' | 'strict'>('standard');
|
||||||
const [sanctumRehearsal, setSanctumRehearsal] = useState<'monthly' | 'quarterly'>('quarterly');
|
const [sanctumRehearsal, setSanctumRehearsal] = useState<'monthly' | 'quarterly'>('quarterly');
|
||||||
|
const [resetVaultFeedback, setResetVaultFeedback] = useState<{ status: 'idle' | 'success' | 'error'; message: string }>({ status: 'idle', message: '' });
|
||||||
const [triggerDisconnectDays, setTriggerDisconnectDays] = useState(30);
|
const [triggerDisconnectDays, setTriggerDisconnectDays] = useState(30);
|
||||||
const [triggerGraceDays, setTriggerGraceDays] = useState(15);
|
const [triggerGraceDays, setTriggerGraceDays] = useState(15);
|
||||||
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
|
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
|
||||||
@@ -294,18 +298,40 @@ export default function MeScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAbandonIsland = () => {
|
const handleAbandonIsland = () => {
|
||||||
Alert.alert(
|
console.log('[MeScreen] Sign out button clicked');
|
||||||
'Sign Out',
|
setShowSignOutModal(true);
|
||||||
'Are you sure you want to sign out?',
|
};
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
const handleConfirmSignOut = () => {
|
||||||
{
|
console.log('[MeScreen] User confirmed sign out');
|
||||||
text: 'Sign Out',
|
setShowSignOutModal(false);
|
||||||
style: 'destructive',
|
signOut();
|
||||||
onPress: signOut
|
};
|
||||||
},
|
|
||||||
]
|
const handleResetVault = async () => {
|
||||||
);
|
setResetVaultFeedback({ status: 'idle', message: '' });
|
||||||
|
const vaultKeys = getVaultStorageKeys(user?.id ?? null);
|
||||||
|
try {
|
||||||
|
await AsyncStorage.multiRemove([
|
||||||
|
vaultKeys.INITIALIZED,
|
||||||
|
vaultKeys.SHARE_DEVICE,
|
||||||
|
vaultKeys.MNEMONIC_PART_LOCAL,
|
||||||
|
]);
|
||||||
|
setResetVaultFeedback({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Vault state has been reset. Next time you open Shadow Vault you will see the mnemonic flow again.',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setResetVaultFeedback({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to reset vault state. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSanctumModal = () => {
|
||||||
|
setResetVaultFeedback({ status: 'idle', message: '' });
|
||||||
|
setShowSanctumModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -618,10 +644,10 @@ export default function MeScreen() {
|
|||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name={isDarkMode ? 'moon' : 'sunny'}
|
name={isDarkMode ? 'moon' : 'sunny'}
|
||||||
size={18}
|
size={18}
|
||||||
color={colors.me.primary}
|
color={colors.me.primary}
|
||||||
/>
|
/>
|
||||||
<Text style={styles.sanctumText}>Dark Mode</Text>
|
<Text style={styles.sanctumText}>Dark Mode</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -749,7 +775,7 @@ export default function MeScreen() {
|
|||||||
visible={showSanctumModal}
|
visible={showSanctumModal}
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
transparent
|
transparent
|
||||||
onRequestClose={() => setShowSanctumModal(false)}
|
onRequestClose={handleCloseSanctumModal}
|
||||||
>
|
>
|
||||||
<View style={styles.spiritOverlay}>
|
<View style={styles.spiritOverlay}>
|
||||||
<View style={styles.spiritModal}>
|
<View style={styles.spiritModal}>
|
||||||
@@ -885,12 +911,51 @@ export default function MeScreen() {
|
|||||||
<Text style={styles.sanctumValue}>View</Text>
|
<Text style={styles.sanctumValue}>View</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{__DEV__ && (
|
||||||
|
<View style={styles.sanctumSection}>
|
||||||
|
<Text style={styles.tideLabel}>DEV ONLY</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.devResetButton}
|
||||||
|
onPress={handleResetVault}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh" size={16} color={colors.nautical.coral} />
|
||||||
|
<Text style={styles.devResetText}>Reset Vault State</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.sanctumHint}>Clear S0 (SHARE_DEVICE) from storage. Next vault open uses mnemonic flow.</Text>
|
||||||
|
{resetVaultFeedback.status !== 'idle' && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.resetVaultFeedback,
|
||||||
|
resetVaultFeedback.status === 'success' ? styles.resetVaultFeedbackSuccess : styles.resetVaultFeedbackError,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={resetVaultFeedback.status === 'success' ? 'checkmark-circle' : 'alert-circle'}
|
||||||
|
size={20}
|
||||||
|
color={resetVaultFeedback.status === 'success' ? colors.sentinel?.statusNormal ?? '#6BBF8A' : colors.nautical.coral}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.resetVaultFeedbackText,
|
||||||
|
resetVaultFeedback.status === 'success' ? styles.resetVaultFeedbackTextSuccess : styles.resetVaultFeedbackTextError,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{resetVaultFeedback.status === 'success' ? 'Success' : 'Error'}
|
||||||
|
{' — '}
|
||||||
|
{resetVaultFeedback.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.tideModalButtons}>
|
<View style={styles.tideModalButtons}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.confirmPulseButton}
|
style={styles.confirmPulseButton}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={() => setShowSanctumModal(false)}
|
onPress={handleCloseSanctumModal}
|
||||||
>
|
>
|
||||||
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
|
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
|
||||||
<Text style={styles.confirmPulseText}>Save</Text>
|
<Text style={styles.confirmPulseText}>Save</Text>
|
||||||
@@ -898,7 +963,7 @@ export default function MeScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.confirmPulseButton}
|
style={styles.confirmPulseButton}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={() => setShowSanctumModal(false)}
|
onPress={handleCloseSanctumModal}
|
||||||
>
|
>
|
||||||
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
|
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
|
||||||
<Text style={styles.confirmPulseText}>Close</Text>
|
<Text style={styles.confirmPulseText}>Close</Text>
|
||||||
@@ -1412,6 +1477,46 @@ export default function MeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* Sign Out Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showSignOutModal}
|
||||||
|
animationType="fade"
|
||||||
|
transparent
|
||||||
|
onRequestClose={() => setShowSignOutModal(false)}
|
||||||
|
>
|
||||||
|
<View style={styles.spiritOverlay}>
|
||||||
|
<View style={styles.signOutModal}>
|
||||||
|
<View style={styles.signOutHeader}>
|
||||||
|
<View style={styles.signOutIcon}>
|
||||||
|
<Feather name="log-out" size={32} color={colors.nautical.coral} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.signOutTitle}>Sign Out</Text>
|
||||||
|
<Text style={styles.signOutMessage}>
|
||||||
|
Are you sure you want to sign out? You'll need to log in again to access your account.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.signOutButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.signOutCancelButton}
|
||||||
|
onPress={() => setShowSignOutModal(false)}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={styles.signOutCancelText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.signOutConfirmButton}
|
||||||
|
onPress={handleConfirmSignOut}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Text style={styles.signOutConfirmText}>Sign Out</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1814,6 +1919,23 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.me.textSecondary,
|
color: colors.me.textSecondary,
|
||||||
},
|
},
|
||||||
|
devResetButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
backgroundColor: `${colors.nautical.coral}15`,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: `${colors.nautical.coral}40`,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
devResetText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.nautical.coral,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
sanctumAlert: {
|
sanctumAlert: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -1827,6 +1949,34 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.nautical.coral,
|
color: colors.nautical.coral,
|
||||||
},
|
},
|
||||||
|
resetVaultFeedback: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.base,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
resetVaultFeedbackSuccess: {
|
||||||
|
backgroundColor: 'rgba(107, 191, 138, 0.2)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(107, 191, 138, 0.5)',
|
||||||
|
},
|
||||||
|
resetVaultFeedbackError: {
|
||||||
|
backgroundColor: 'rgba(229, 115, 115, 0.2)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(229, 115, 115, 0.5)',
|
||||||
|
},
|
||||||
|
resetVaultFeedbackText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
resetVaultFeedbackTextSuccess: {
|
||||||
|
color: '#2E7D5E',
|
||||||
|
},
|
||||||
|
resetVaultFeedbackTextError: {
|
||||||
|
color: colors.nautical.coral,
|
||||||
|
},
|
||||||
confirmPulseButton: {
|
confirmPulseButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -2333,4 +2483,71 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
},
|
},
|
||||||
|
// Sign Out Modal Styles
|
||||||
|
signOutModal: {
|
||||||
|
backgroundColor: colors.me.cardBackground,
|
||||||
|
borderRadius: borderRadius.xl,
|
||||||
|
padding: spacing.xl,
|
||||||
|
marginHorizontal: spacing.xl,
|
||||||
|
maxWidth: 400,
|
||||||
|
width: '100%',
|
||||||
|
...shadows.medium,
|
||||||
|
},
|
||||||
|
signOutHeader: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
signOutIcon: {
|
||||||
|
width: 72,
|
||||||
|
height: 72,
|
||||||
|
borderRadius: 36,
|
||||||
|
backgroundColor: `${colors.nautical.coral}15`,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: spacing.base,
|
||||||
|
},
|
||||||
|
signOutTitle: {
|
||||||
|
fontSize: typography.fontSize.xl,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.me.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
signOutMessage: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.me.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: typography.fontSize.base * 1.5,
|
||||||
|
},
|
||||||
|
signOutButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
signOutCancelButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: spacing.base,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
backgroundColor: colors.me.cardBorder,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
signOutCancelText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.me.text,
|
||||||
|
},
|
||||||
|
signOutConfirmButton: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: spacing.base,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
backgroundColor: colors.nautical.coral,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
signOutConfirmText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,11 +15,22 @@ import { colors, typography, spacing, borderRadius, shadows } from '../theme/col
|
|||||||
import { SystemStatus, KillSwitchLog } from '../types';
|
import { SystemStatus, KillSwitchLog } from '../types';
|
||||||
import VaultScreen from './VaultScreen';
|
import VaultScreen from './VaultScreen';
|
||||||
|
|
||||||
|
// Animation timing constants
|
||||||
|
const ANIMATION_DURATION = {
|
||||||
|
pulse: 1200,
|
||||||
|
glow: 1500,
|
||||||
|
rotate: 30000,
|
||||||
|
heartbeatPress: 150,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Icon names type for type safety
|
||||||
|
type StatusIconName = 'checkmark-circle' | 'warning' | 'alert-circle';
|
||||||
|
|
||||||
// Status configuration with nautical theme
|
// Status configuration with nautical theme
|
||||||
const statusConfig: Record<SystemStatus, {
|
const statusConfig: Record<SystemStatus, {
|
||||||
color: string;
|
color: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: string;
|
icon: StatusIconName;
|
||||||
description: string;
|
description: string;
|
||||||
gradientColors: [string, string];
|
gradientColors: [string, string];
|
||||||
}> = {
|
}> = {
|
||||||
@@ -48,28 +59,14 @@ const statusConfig: Record<SystemStatus, {
|
|||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const initialLogs: KillSwitchLog[] = [
|
const initialLogs: KillSwitchLog[] = [
|
||||||
{
|
{ id: '1', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-18T09:30:00') },
|
||||||
id: '1',
|
{ id: '2', action: 'SUBSCRIPTION_VERIFIED', timestamp: new Date('2024-01-17T00:00:00') },
|
||||||
action: 'HEARTBEAT_CONFIRMED',
|
{ id: '3', action: 'JOURNAL_ACTIVITY', timestamp: new Date('2024-01-16T15:42:00') },
|
||||||
timestamp: new Date('2024-01-18T09:30:00'),
|
{ id: '4', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-15T11:20:00') },
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
action: 'SUBSCRIPTION_VERIFIED',
|
|
||||||
timestamp: new Date('2024-01-17T00:00:00'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
action: 'JOURNAL_ACTIVITY',
|
|
||||||
timestamp: new Date('2024-01-16T15:42:00'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
action: 'HEARTBEAT_CONFIRMED',
|
|
||||||
timestamp: new Date('2024-01-15T11:20:00'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export { VAULT_STORAGE_KEYS } from '../config';
|
||||||
|
|
||||||
export default function SentinelScreen() {
|
export default function SentinelScreen() {
|
||||||
const [status, setStatus] = useState<SystemStatus>('normal');
|
const [status, setStatus] = useState<SystemStatus>('normal');
|
||||||
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
|
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
|
||||||
@@ -81,104 +78,98 @@ export default function SentinelScreen() {
|
|||||||
const [showVault, setShowVault] = useState(false);
|
const [showVault, setShowVault] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Pulse animation
|
const pulseAnimation = Animated.loop(
|
||||||
Animated.loop(
|
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(pulseAnim, {
|
Animated.timing(pulseAnim, {
|
||||||
toValue: 1.06,
|
toValue: 1.06,
|
||||||
duration: 1200,
|
duration: ANIMATION_DURATION.pulse,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
Animated.timing(pulseAnim, {
|
Animated.timing(pulseAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 1200,
|
duration: ANIMATION_DURATION.pulse,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
).start();
|
);
|
||||||
|
pulseAnimation.start();
|
||||||
|
|
||||||
// Glow animation
|
const glowAnimation = Animated.loop(
|
||||||
Animated.loop(
|
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(glowAnim, {
|
Animated.timing(glowAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 1500,
|
duration: ANIMATION_DURATION.glow,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
Animated.timing(glowAnim, {
|
Animated.timing(glowAnim, {
|
||||||
toValue: 0.5,
|
toValue: 0.5,
|
||||||
duration: 1500,
|
duration: ANIMATION_DURATION.glow,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
).start();
|
);
|
||||||
|
glowAnimation.start();
|
||||||
|
|
||||||
// Slow rotate for ship wheel
|
const rotateAnimation = Animated.loop(
|
||||||
Animated.loop(
|
|
||||||
Animated.timing(rotateAnim, {
|
Animated.timing(rotateAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 30000,
|
duration: ANIMATION_DURATION.rotate,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
})
|
})
|
||||||
).start();
|
);
|
||||||
}, []);
|
rotateAnimation.start();
|
||||||
|
|
||||||
const openVault = () => {
|
return () => {
|
||||||
setShowVault(true);
|
pulseAnimation.stop();
|
||||||
};
|
glowAnimation.stop();
|
||||||
|
rotateAnimation.stop();
|
||||||
|
};
|
||||||
|
}, [pulseAnim, glowAnim, rotateAnim]);
|
||||||
|
|
||||||
|
const openVault = () => setShowVault(true);
|
||||||
|
|
||||||
const handleHeartbeat = () => {
|
const handleHeartbeat = () => {
|
||||||
// Animate pulse
|
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(pulseAnim, {
|
Animated.timing(pulseAnim, {
|
||||||
toValue: 1.15,
|
toValue: 1.15,
|
||||||
duration: 150,
|
duration: ANIMATION_DURATION.heartbeatPress,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
Animated.timing(pulseAnim, {
|
Animated.timing(pulseAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 150,
|
duration: ANIMATION_DURATION.heartbeatPress,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
|
|
||||||
// Add new log
|
|
||||||
const newLog: KillSwitchLog = {
|
const newLog: KillSwitchLog = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
action: 'HEARTBEAT_CONFIRMED',
|
action: 'HEARTBEAT_CONFIRMED',
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
};
|
};
|
||||||
setLogs([newLog, ...logs]);
|
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
||||||
|
|
||||||
// Reset status if warning
|
|
||||||
if (status === 'warning') {
|
if (status === 'warning') {
|
||||||
setStatus('normal');
|
setStatus('normal');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateTime = (date: Date) => {
|
const formatDateTime = (date: Date) =>
|
||||||
return date.toLocaleString('en-US', {
|
date.toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimeAgo = (date: Date) => {
|
const formatTimeAgo = (date: Date) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now.getTime() - date.getTime();
|
const diff = now.getTime() - date.getTime();
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
if (hours > 24) return `${Math.floor(hours / 24)} days ago`;
|
||||||
if (hours > 24) {
|
if (hours > 0) return `${hours}h ${minutes}m ago`;
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days} days ago`;
|
|
||||||
}
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m ago`;
|
|
||||||
}
|
|
||||||
return `${minutes}m ago`;
|
return `${minutes}m ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,7 +186,7 @@ export default function SentinelScreen() {
|
|||||||
style={styles.gradient}
|
style={styles.gradient}
|
||||||
>
|
>
|
||||||
<SafeAreaView style={styles.safeArea}>
|
<SafeAreaView style={styles.safeArea}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
@@ -211,14 +202,14 @@ export default function SentinelScreen() {
|
|||||||
|
|
||||||
{/* Status Display */}
|
{/* Status Display */}
|
||||||
<View style={styles.statusContainer}>
|
<View style={styles.statusContainer}>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.statusCircleOuter,
|
styles.statusCircleOuter,
|
||||||
{
|
{
|
||||||
transform: [{ scale: pulseAnim }],
|
transform: [{ scale: pulseAnim }],
|
||||||
opacity: glowAnim,
|
opacity: glowAnim,
|
||||||
backgroundColor: `${currentStatus.color}20`,
|
backgroundColor: `${currentStatus.color}20`,
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||||
@@ -226,7 +217,7 @@ export default function SentinelScreen() {
|
|||||||
colors={currentStatus.gradientColors}
|
colors={currentStatus.gradientColors}
|
||||||
style={styles.statusCircle}
|
style={styles.statusCircle}
|
||||||
>
|
>
|
||||||
<Ionicons name={currentStatus.icon as any} size={56} color="#fff" />
|
<Ionicons name={currentStatus.icon} size={56} color="#fff" />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
<Text style={[styles.statusLabel, { color: currentStatus.color }]}>
|
<Text style={[styles.statusLabel, { color: currentStatus.color }]}>
|
||||||
@@ -240,10 +231,10 @@ export default function SentinelScreen() {
|
|||||||
{/* Ship Wheel Watermark */}
|
{/* Ship Wheel Watermark */}
|
||||||
<View style={styles.wheelWatermark}>
|
<View style={styles.wheelWatermark}>
|
||||||
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
<Animated.View style={{ transform: [{ rotate: spin }] }}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="ship-wheel"
|
name="ship-wheel"
|
||||||
size={200}
|
size={200}
|
||||||
color={colors.sentinel.primary}
|
color={colors.sentinel.primary}
|
||||||
style={{ opacity: 0.03 }}
|
style={{ opacity: 0.03 }}
|
||||||
/>
|
/>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
@@ -256,24 +247,16 @@ export default function SentinelScreen() {
|
|||||||
<FontAwesome5 name="anchor" size={16} color={colors.sentinel.primary} />
|
<FontAwesome5 name="anchor" size={16} color={colors.sentinel.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.metricLabel}>SUBSCRIPTION</Text>
|
<Text style={styles.metricLabel}>SUBSCRIPTION</Text>
|
||||||
<Text style={styles.metricValue}>
|
<Text style={styles.metricValue}>{formatTimeAgo(lastSubscriptionCheck)}</Text>
|
||||||
{formatTimeAgo(lastSubscriptionCheck)}
|
<Text style={styles.metricTime}>{formatDateTime(lastSubscriptionCheck)}</Text>
|
||||||
</Text>
|
|
||||||
<Text style={styles.metricTime}>
|
|
||||||
{formatDateTime(lastSubscriptionCheck)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.metricCard}>
|
<View style={styles.metricCard}>
|
||||||
<View style={styles.metricIconContainer}>
|
<View style={styles.metricIconContainer}>
|
||||||
<Feather name="edit-3" size={16} color={colors.sentinel.primary} />
|
<Feather name="edit-3" size={16} color={colors.sentinel.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.metricLabel}>LAST JOURNAL</Text>
|
<Text style={styles.metricLabel}>LAST JOURNAL</Text>
|
||||||
<Text style={styles.metricValue}>
|
<Text style={styles.metricValue}>{formatTimeAgo(lastFlowActivity)}</Text>
|
||||||
{formatTimeAgo(lastFlowActivity)}
|
<Text style={styles.metricTime}>{formatDateTime(lastFlowActivity)}</Text>
|
||||||
</Text>
|
|
||||||
<Text style={styles.metricTime}>
|
|
||||||
{formatDateTime(lastFlowActivity)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -292,6 +275,8 @@ export default function SentinelScreen() {
|
|||||||
style={styles.vaultAccessButton}
|
style={styles.vaultAccessButton}
|
||||||
onPress={openVault}
|
onPress={openVault}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
accessibilityLabel="Open Shadow Vault"
|
||||||
|
accessibilityRole="button"
|
||||||
>
|
>
|
||||||
<Text style={styles.vaultAccessButtonText}>Open</Text>
|
<Text style={styles.vaultAccessButtonText}>Open</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -302,6 +287,8 @@ export default function SentinelScreen() {
|
|||||||
style={styles.heartbeatButton}
|
style={styles.heartbeatButton}
|
||||||
onPress={handleHeartbeat}
|
onPress={handleHeartbeat}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.9}
|
||||||
|
accessibilityLabel="Signal the watch - Confirm your presence"
|
||||||
|
accessibilityRole="button"
|
||||||
>
|
>
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[colors.nautical.teal, colors.nautical.seafoam]}
|
colors={[colors.nautical.teal, colors.nautical.seafoam]}
|
||||||
@@ -330,9 +317,7 @@ export default function SentinelScreen() {
|
|||||||
<View style={styles.logDot} />
|
<View style={styles.logDot} />
|
||||||
<View style={styles.logContent}>
|
<View style={styles.logContent}>
|
||||||
<Text style={styles.logAction}>{log.action}</Text>
|
<Text style={styles.logAction}>{log.action}</Text>
|
||||||
<Text style={styles.logTime}>
|
<Text style={styles.logTime}>{formatDateTime(log.timestamp)}</Text>
|
||||||
{formatDateTime(log.timestamp)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -353,36 +338,27 @@ export default function SentinelScreen() {
|
|||||||
style={styles.vaultCloseButton}
|
style={styles.vaultCloseButton}
|
||||||
onPress={() => setShowVault(false)}
|
onPress={() => setShowVault(false)}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
|
accessibilityLabel="Close vault"
|
||||||
|
accessibilityRole="button"
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={20} color={colors.nautical.cream} />
|
<Ionicons name="close" size={20} color={colors.nautical.cream} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: { flex: 1 },
|
||||||
flex: 1,
|
gradient: { flex: 1 },
|
||||||
},
|
safeArea: { flex: 1 },
|
||||||
gradient: {
|
scrollView: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
paddingBottom: 120,
|
paddingBottom: 120,
|
||||||
},
|
},
|
||||||
header: {
|
header: { marginBottom: spacing.xl },
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
headerTitleRow: {
|
headerTitleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -488,9 +464,7 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: spacing.xl,
|
marginBottom: spacing.xl,
|
||||||
...shadows.medium,
|
...shadows.medium,
|
||||||
},
|
},
|
||||||
heartbeatGradient: {
|
heartbeatGradient: { padding: spacing.lg },
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
heartbeatContent: {
|
heartbeatContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -544,9 +518,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
logContent: {
|
logContent: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
logAction: {
|
logAction: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.sentinel.text,
|
color: colors.sentinel.text,
|
||||||
@@ -559,7 +531,6 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.sentinel.textSecondary,
|
color: colors.sentinel.textSecondary,
|
||||||
fontFamily: typography.fontFamily.mono,
|
fontFamily: typography.fontFamily.mono,
|
||||||
},
|
},
|
||||||
// Shadow Vault Access Card
|
|
||||||
vaultAccessCard: {
|
vaultAccessCard: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -579,9 +550,7 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
vaultAccessContent: {
|
vaultAccessContent: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
vaultAccessTitle: {
|
vaultAccessTitle: {
|
||||||
fontSize: typography.fontSize.base,
|
fontSize: typography.fontSize.base,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -603,7 +572,6 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
},
|
},
|
||||||
// Vault Modal
|
|
||||||
vaultModalContainer: {
|
vaultModalContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.vault.background,
|
backgroundColor: colors.vault.background,
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
getApiHeaders,
|
getApiHeaders,
|
||||||
logApiDebug,
|
logApiDebug,
|
||||||
} from '../config';
|
} from '../config';
|
||||||
|
import { AIRole } from '../types';
|
||||||
|
import { trimInternalMessages } from '../utils/token_utils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Type Definitions
|
// Type Definitions
|
||||||
@@ -95,7 +97,7 @@ export const aiService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
|
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
|
||||||
|
|
||||||
logApiDebug('AI Request', {
|
logApiDebug('AI Request', {
|
||||||
url,
|
url,
|
||||||
hasToken: !!token,
|
hasToken: !!token,
|
||||||
@@ -114,7 +116,7 @@ export const aiService = {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logApiDebug('AI Error Response', errorText);
|
logApiDebug('AI Error Response', errorText);
|
||||||
|
|
||||||
let errorDetail = 'AI request failed';
|
let errorDetail = 'AI request failed';
|
||||||
try {
|
try {
|
||||||
const errorData = JSON.parse(errorText);
|
const errorData = JSON.parse(errorText);
|
||||||
@@ -131,7 +133,7 @@ export const aiService = {
|
|||||||
model: data.model,
|
model: data.model,
|
||||||
choicesCount: data.choices?.length,
|
choicesCount: data.choices?.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI proxy error:', error);
|
console.error('AI proxy error:', error);
|
||||||
@@ -143,13 +145,14 @@ export const aiService = {
|
|||||||
* Simple helper for single message chat
|
* Simple helper for single message chat
|
||||||
* @param content - User message content
|
* @param content - User message content
|
||||||
* @param token - JWT token for authentication
|
* @param token - JWT token for authentication
|
||||||
|
* @param systemPrompt - Optional custom system prompt
|
||||||
* @returns AI response text
|
* @returns AI response text
|
||||||
*/
|
*/
|
||||||
async sendMessage(content: string, token?: string): Promise<string> {
|
async sendMessage(content: string, token?: string, systemPrompt?: string): Promise<string> {
|
||||||
const messages: AIMessage[] = [
|
const messages: AIMessage[] = [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
|
content: systemPrompt || AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -179,7 +182,7 @@ export const aiService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
|
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
|
||||||
|
|
||||||
logApiDebug('AI Image Request', {
|
logApiDebug('AI Image Request', {
|
||||||
url,
|
url,
|
||||||
hasToken: !!token,
|
hasToken: !!token,
|
||||||
@@ -217,7 +220,7 @@ export const aiService = {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
logApiDebug('AI Image Error Response', errorText);
|
logApiDebug('AI Image Error Response', errorText);
|
||||||
|
|
||||||
let errorDetail = 'AI image request failed';
|
let errorDetail = 'AI image request failed';
|
||||||
try {
|
try {
|
||||||
const errorData = JSON.parse(errorText);
|
const errorData = JSON.parse(errorText);
|
||||||
@@ -233,11 +236,93 @@ export const aiService = {
|
|||||||
id: data.id,
|
id: data.id,
|
||||||
model: data.model,
|
model: data.model,
|
||||||
});
|
});
|
||||||
|
|
||||||
return data.choices[0]?.message?.content || 'No response';
|
return data.choices[0]?.message?.content || 'No response';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI image proxy error:', error);
|
console.error('AI image proxy error:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize a chat conversation
|
||||||
|
* @param messages - Array of chat messages
|
||||||
|
* @param token - JWT token for authentication
|
||||||
|
* @returns AI summary text
|
||||||
|
*/
|
||||||
|
async summarizeChat(messages: AIMessage[], token?: string): Promise<string> {
|
||||||
|
if (NO_BACKEND_MODE) {
|
||||||
|
logApiDebug('AI Summary', 'Using mock mode');
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve('This is a mock summary of your conversation. You discussed various topics including AI integration and UI design. The main conclusion was to proceed with the proposed implementation plan.');
|
||||||
|
}, AI_CONFIG.MOCK_RESPONSE_DELAY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce token limit (10,000 tokens)
|
||||||
|
const trimmedMessages = trimInternalMessages(messages);
|
||||||
|
|
||||||
|
const historicalMessages = trimmedMessages.map(msg => ({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summaryPrompt: AIMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: 'Please provide a concise summary of the conversation above in English. Focus on the main topics discussed and any key conclusions or actions mentioned.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.chat([...historicalMessages, summaryPrompt], token);
|
||||||
|
return response.choices[0]?.message?.content || 'No summary generated';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch available AI roles from backend
|
||||||
|
* @param token - Optional JWT token for authentication
|
||||||
|
* @returns Array of AI roles
|
||||||
|
*/
|
||||||
|
async getAIRoles(token?: string): Promise<AIRole[]> {
|
||||||
|
if (NO_BACKEND_MODE) {
|
||||||
|
logApiDebug('AI Roles', 'Using mock roles');
|
||||||
|
return [...AI_CONFIG.ROLES];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.warn('[AI Service] getAIRoles called without token, falling back to static roles');
|
||||||
|
return [...AI_CONFIG.ROLES];
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildApiUrl(API_ENDPOINTS.AI.GET_ROLES);
|
||||||
|
const headers = getApiHeaders(token);
|
||||||
|
|
||||||
|
logApiDebug('AI Roles Request', {
|
||||||
|
url,
|
||||||
|
hasToken: !!token,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
Authorization: headers.Authorization ? `${headers.Authorization.substring(0, 15)}...` : 'MISSING'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[AI Service] Failed to fetch AI roles: ${response.status}. Falling back to static roles.`);
|
||||||
|
return [...AI_CONFIG.ROLES];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
logApiDebug('AI Roles Success', { count: data.length });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AI Service] Fetch AI roles error:', error);
|
||||||
|
// Fallback to config roles if API fails for better UX
|
||||||
|
return [...AI_CONFIG.ROLES];
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface Asset {
|
|||||||
author_id: number;
|
author_id: number;
|
||||||
private_key_shard: string;
|
private_key_shard: string;
|
||||||
content_outer_encrypted: string;
|
content_outer_encrypted: string;
|
||||||
|
heir_email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetCreate {
|
export interface AssetCreate {
|
||||||
@@ -45,7 +46,7 @@ export interface AssetClaimResponse {
|
|||||||
|
|
||||||
export interface AssetAssign {
|
export interface AssetAssign {
|
||||||
asset_id: number;
|
asset_id: number;
|
||||||
heir_name: string;
|
heir_email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -59,6 +60,7 @@ const MOCK_ASSETS: Asset[] = [
|
|||||||
author_id: MOCK_CONFIG.USER.id,
|
author_id: MOCK_CONFIG.USER.id,
|
||||||
private_key_shard: 'mock_shard_1',
|
private_key_shard: 'mock_shard_1',
|
||||||
content_outer_encrypted: 'mock_encrypted_content_1',
|
content_outer_encrypted: 'mock_encrypted_content_1',
|
||||||
|
heir_email: 'heir@example.com',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -142,11 +144,16 @@ export const assetsService = {
|
|||||||
body: JSON.stringify(asset),
|
body: JSON.stringify(asset),
|
||||||
});
|
});
|
||||||
|
|
||||||
logApiDebug('Create Asset Response Status', response.status);
|
const responseStatus = response.status;
|
||||||
|
logApiDebug('Create Asset Response Status', responseStatus);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
throw new Error(errorData.detail || 'Failed to create asset');
|
const detail = errorData.detail || 'Failed to create asset';
|
||||||
|
const message = responseStatus === 401 ? `Unauthorized (401): ${detail}` : detail;
|
||||||
|
const err = new Error(message) as Error & { status?: number };
|
||||||
|
err.status = responseStatus;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
@@ -212,7 +219,7 @@ export const assetsService = {
|
|||||||
logApiDebug('Assign Asset', 'Using mock mode');
|
logApiDebug('Assign Asset', 'Using mock mode');
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resolve({ message: `Asset assigned to ${assignment.heir_name}` });
|
resolve({ message: `Asset assigned to ${assignment.heir_email}` });
|
||||||
}, MOCK_CONFIG.RESPONSE_DELAY);
|
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -240,4 +247,44 @@ export const assetsService = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an asset
|
||||||
|
* @param assetId - ID of the asset to delete
|
||||||
|
* @param token - JWT token for authentication
|
||||||
|
* @returns Success message
|
||||||
|
*/
|
||||||
|
async deleteAsset(assetId: number, token: string): Promise<{ message: string }> {
|
||||||
|
if (NO_BACKEND_MODE) {
|
||||||
|
logApiDebug('Delete Asset', `Using mock mode for ID: ${assetId}`);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({ message: 'Asset deleted successfully' });
|
||||||
|
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildApiUrl(API_ENDPOINTS.ASSETS.DELETE);
|
||||||
|
logApiDebug('Delete Asset URL', url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getApiHeaders(token),
|
||||||
|
body: JSON.stringify({ asset_id: assetId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
logApiDebug('Delete Asset Response Status', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || 'Failed to delete asset');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete asset error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,3 +23,9 @@ export {
|
|||||||
type DeclareGualeRequest,
|
type DeclareGualeRequest,
|
||||||
type DeclareGualeResponse
|
type DeclareGualeResponse
|
||||||
} from './admin.service';
|
} from './admin.service';
|
||||||
|
export {
|
||||||
|
createVaultPayload,
|
||||||
|
createAssetPayload,
|
||||||
|
type CreateVaultPayloadResult,
|
||||||
|
type CreateAssetPayloadResult,
|
||||||
|
} from './vault.service';
|
||||||
|
|||||||
96
src/services/langgraph.service.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* LangGraph Service
|
||||||
|
*
|
||||||
|
* Implements AI chat logic using LangGraph.js for state management
|
||||||
|
* and context handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
|
||||||
|
import { BaseMessage, HumanMessage, AIMessage, SystemMessage } from "@langchain/core/messages";
|
||||||
|
import { aiService } from "./ai.service";
|
||||||
|
import { trimLangChainMessages } from "../utils/token_utils";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Settings
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the State using Annotation (Standard for latest LangGraph.js)
|
||||||
|
*/
|
||||||
|
const GraphAnnotation = Annotation.Root({
|
||||||
|
messages: Annotation<BaseMessage[]>({
|
||||||
|
reducer: (x, y) => x.concat(y),
|
||||||
|
default: () => [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Graph Definition
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main node that calls our existing AI API
|
||||||
|
*/
|
||||||
|
async function callModel(state: typeof GraphAnnotation.State, config: any) {
|
||||||
|
const { messages } = state;
|
||||||
|
const { token } = config.configurable || {};
|
||||||
|
|
||||||
|
// 1. Trim messages to stay under token limit
|
||||||
|
const trimmedMessages = trimLangChainMessages(messages);
|
||||||
|
|
||||||
|
// 2. Convert LangChain messages to our internal AIMessage format for the API
|
||||||
|
const apiMessages = trimmedMessages.map(m => {
|
||||||
|
let role: 'system' | 'user' | 'assistant' = 'user';
|
||||||
|
const type = (m as any)._getType?.() || (m instanceof SystemMessage ? 'system' : m instanceof HumanMessage ? 'human' : m instanceof AIMessage ? 'ai' : 'user');
|
||||||
|
|
||||||
|
if (type === 'system') role = 'system';
|
||||||
|
else if (type === 'human') role = 'user';
|
||||||
|
else if (type === 'ai') role = 'assistant';
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
content: m.content.toString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Call the proxy service
|
||||||
|
const response = await aiService.chat(apiMessages, token);
|
||||||
|
const content = response.choices[0]?.message?.content || "No response generated";
|
||||||
|
|
||||||
|
// 4. Return the new message to satisfy the Graph (it will be appended due to reducer)
|
||||||
|
return {
|
||||||
|
messages: [new AIMessage(content)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Service Export
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const langGraphService = {
|
||||||
|
/**
|
||||||
|
* Run the chat graph with history
|
||||||
|
*/
|
||||||
|
async execute(
|
||||||
|
currentMessages: BaseMessage[],
|
||||||
|
userToken: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// Define the graph
|
||||||
|
const workflow = new StateGraph(GraphAnnotation)
|
||||||
|
.addNode("agent", callModel)
|
||||||
|
.addEdge(START, "agent")
|
||||||
|
.addEdge("agent", END);
|
||||||
|
|
||||||
|
const app = workflow.compile();
|
||||||
|
|
||||||
|
// Execute the graph
|
||||||
|
const result = await app.invoke(
|
||||||
|
{ messages: currentMessages },
|
||||||
|
{ configurable: { token: userToken } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the content of the last message (the AI response)
|
||||||
|
const lastMsg = result.messages[result.messages.length - 1];
|
||||||
|
return lastMsg.content.toString();
|
||||||
|
}
|
||||||
|
};
|
||||||
147
src/services/storage.service.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Storage Service
|
||||||
|
*
|
||||||
|
* Handles local persistence of chat history and active conversations
|
||||||
|
* using AsyncStorage with user-specific isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Constants
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
const STORAGE_KEYS = {
|
||||||
|
CHAT_HISTORY: '@sentinel:chat_history',
|
||||||
|
CURRENT_MESSAGES: '@sentinel:current_messages',
|
||||||
|
ASSET_BACKUP: '@sentinel:asset_backup',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Service Implementation
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const storageService = {
|
||||||
|
/**
|
||||||
|
* Get user-specific storage key
|
||||||
|
*/
|
||||||
|
getUserKey(baseKey: string, userId: string | number): string {
|
||||||
|
return `${baseKey}:user_${userId}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the complete list of chat sessions to local storage for a specific user
|
||||||
|
*/
|
||||||
|
async saveChatHistory(history: any[], userId: string | number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const jsonValue = JSON.stringify(history);
|
||||||
|
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
|
||||||
|
await AsyncStorage.setItem(key, jsonValue);
|
||||||
|
console.log(`[Storage] Saved chat history for user ${userId}:`, history.length, 'sessions');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error saving chat history:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the list of chat sessions from local storage for a specific user
|
||||||
|
*/
|
||||||
|
async getChatHistory(userId: string | number): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
|
||||||
|
const jsonValue = await AsyncStorage.getItem(key);
|
||||||
|
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
|
||||||
|
console.log(`[Storage] Loaded chat history for user ${userId}:`, result.length, 'sessions');
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error getting chat history:', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the current active conversation messages for a specific role and user
|
||||||
|
*/
|
||||||
|
async saveCurrentChat(roleId: string, messages: any[], userId: string | number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const jsonValue = JSON.stringify(messages);
|
||||||
|
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
|
||||||
|
await AsyncStorage.setItem(key, jsonValue);
|
||||||
|
console.log(`[Storage] Saved current chat for user ${userId}, role ${roleId}:`, messages.length, 'messages');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error saving current chat for role ${roleId}:`, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the current active conversation messages for a specific role and user
|
||||||
|
*/
|
||||||
|
async getCurrentChat(roleId: string, userId: string | number): Promise<any[]> {
|
||||||
|
try {
|
||||||
|
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
|
||||||
|
const jsonValue = await AsyncStorage.getItem(key);
|
||||||
|
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
|
||||||
|
console.log(`[Storage] Loaded current chat for user ${userId}, role ${roleId}:`, result.length, 'messages');
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error getting current chat for role ${roleId}:`, e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all stored chat data for a specific user
|
||||||
|
*/
|
||||||
|
async clearUserData(userId: string | number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const keys = await AsyncStorage.getAllKeys();
|
||||||
|
const userPrefix = `:user_${userId}`;
|
||||||
|
const userKeys = keys.filter(key => key.includes(userPrefix));
|
||||||
|
await AsyncStorage.multiRemove(userKeys);
|
||||||
|
console.log(`[Storage] Cleared all data for user ${userId}:`, userKeys.length, 'keys removed');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error clearing user data:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all stored chat data (all users)
|
||||||
|
*/
|
||||||
|
async clearAllData(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const keys = await AsyncStorage.getAllKeys();
|
||||||
|
const sentinelKeys = keys.filter(key => key.startsWith('@sentinel:'));
|
||||||
|
await AsyncStorage.multiRemove(sentinelKeys);
|
||||||
|
console.log('[Storage] Cleared all data:', sentinelKeys.length, 'keys removed');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error clearing storage data:', e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the plaintext backup of an asset locally
|
||||||
|
*/
|
||||||
|
async saveAssetBackup(assetId: number, content: string, userId: string | number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
|
||||||
|
await AsyncStorage.setItem(key, content);
|
||||||
|
console.log(`[Storage] Saved asset backup for user ${userId}, asset ${assetId}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error saving asset backup for asset ${assetId}:`, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the plaintext backup of an asset locally
|
||||||
|
*/
|
||||||
|
async getAssetBackup(assetId: number, userId: string | number): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
|
||||||
|
return await AsyncStorage.getItem(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error getting asset backup for asset ${assetId}:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
81
src/services/vault.service.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Vault Service: 为 /assets/create 生成 private_key_shard 与 content_inner_encrypted
|
||||||
|
*
|
||||||
|
* 流程(与后端 test_scenario / SentinelVault 一致):
|
||||||
|
* 1. 用 SSS 生成助记词并分片 → 选一个分片作为 private_key_shard(存后端,继承时返回)
|
||||||
|
* 2. 用助记词派生 AES 密钥,对明文做 AES-GCM 加密 → content_inner_encrypted(hex 字符串)
|
||||||
|
*
|
||||||
|
* 使用方式:在任意页面调用 createVaultPayload(plaintext, wordList),得到可直接传给 assetsService.createAsset 的字段。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateVaultKeys,
|
||||||
|
serializeShare,
|
||||||
|
type SSSShare,
|
||||||
|
type VaultKeyData,
|
||||||
|
} from '../utils/sss';
|
||||||
|
import { deriveKey, encryptDataGCM, bytesToHex } from '../utils/vaultCrypto';
|
||||||
|
|
||||||
|
export interface CreateVaultPayloadResult {
|
||||||
|
/** 传给后端的 private_key_shard(存一个 SSS 分片的序列化字符串,如云端分片) */
|
||||||
|
private_key_shard: string;
|
||||||
|
/** 传给后端的 content_inner_encrypted(AES-GCM 密文的 hex) */
|
||||||
|
content_inner_encrypted: string;
|
||||||
|
/** 本次生成的助记词(用户需妥善保管,恢复时需任意 2 个分片) */
|
||||||
|
mnemonic: string[];
|
||||||
|
/** 三个分片:device / cloud / heir,可与后端返回的 server_shard 组合恢复助记词 */
|
||||||
|
shares: SSSShare[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAssetPayloadResult {
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
private_key_shard: string;
|
||||||
|
content_inner_encrypted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成金库:助记词 + SSS 分片 + 内层加密内容
|
||||||
|
* @param plaintext 要加密的明文(如遗产说明、账号密码等)
|
||||||
|
* @param wordList 助记词词表(与 sss 使用的词表一致)
|
||||||
|
* @param shareIndexForServer 哪个分片存后端,0=device, 1=cloud, 2=heir,默认 1(云端)
|
||||||
|
*/
|
||||||
|
export async function createVaultPayload(
|
||||||
|
plaintext: string,
|
||||||
|
wordList: readonly string[],
|
||||||
|
shareIndexForServer: 0 | 1 | 2 = 1
|
||||||
|
): Promise<CreateVaultPayloadResult> {
|
||||||
|
const { mnemonic, shares }: VaultKeyData = generateVaultKeys(wordList, 12);
|
||||||
|
const mnemonicPhrase = mnemonic.join(' ');
|
||||||
|
const key = await deriveKey(mnemonicPhrase);
|
||||||
|
const encrypted = await encryptDataGCM(key, plaintext);
|
||||||
|
const content_inner_encrypted = bytesToHex(encrypted);
|
||||||
|
const shareForServer = shares[shareIndexForServer];
|
||||||
|
const private_key_shard = serializeShare(shareForServer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
private_key_shard,
|
||||||
|
content_inner_encrypted,
|
||||||
|
mnemonic,
|
||||||
|
shares,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成可直接用于 POST /assets/create 的请求体(含 title / type)
|
||||||
|
*/
|
||||||
|
export async function createAssetPayload(
|
||||||
|
title: string,
|
||||||
|
plaintext: string,
|
||||||
|
wordList: readonly string[],
|
||||||
|
assetType: string = 'note',
|
||||||
|
shareIndexForServer: 0 | 1 | 2 = 1
|
||||||
|
): Promise<CreateAssetPayloadResult> {
|
||||||
|
const vault = await createVaultPayload(plaintext, wordList, shareIndexForServer);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
type: assetType,
|
||||||
|
private_key_shard: vault.private_key_shard,
|
||||||
|
content_inner_encrypted: vault.content_inner_encrypted,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ export const colors = {
|
|||||||
// Base colors
|
// Base colors
|
||||||
white: '#FFFFFF',
|
white: '#FFFFFF',
|
||||||
black: '#1A2F3A',
|
black: '#1A2F3A',
|
||||||
|
|
||||||
// Nautical palette
|
// Nautical palette
|
||||||
nautical: {
|
nautical: {
|
||||||
deepTeal: '#1B4D5C',
|
deepTeal: '#1B4D5C',
|
||||||
@@ -21,7 +21,7 @@ export const colors = {
|
|||||||
navy: '#1A3A4A',
|
navy: '#1A3A4A',
|
||||||
sage: '#8CA5A5',
|
sage: '#8CA5A5',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Flow - Captain's Journal
|
// Flow - Captain's Journal
|
||||||
flow: {
|
flow: {
|
||||||
background: '#E8F6F8',
|
background: '#E8F6F8',
|
||||||
@@ -38,7 +38,7 @@ export const colors = {
|
|||||||
archivedText: '#7A9A9A',
|
archivedText: '#7A9A9A',
|
||||||
highlight: '#B8E0E5',
|
highlight: '#B8E0E5',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Vault - Ship's Vault
|
// Vault - Ship's Vault
|
||||||
vault: {
|
vault: {
|
||||||
background: '#1B4D5C',
|
background: '#1B4D5C',
|
||||||
@@ -54,7 +54,7 @@ export const colors = {
|
|||||||
warning: '#E57373',
|
warning: '#E57373',
|
||||||
success: '#6BBF8A',
|
success: '#6BBF8A',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Sentinel - Lighthouse Watch
|
// Sentinel - Lighthouse Watch
|
||||||
sentinel: {
|
sentinel: {
|
||||||
background: '#1A3A4A',
|
background: '#1A3A4A',
|
||||||
@@ -70,7 +70,7 @@ export const colors = {
|
|||||||
statusWarning: '#E5B873',
|
statusWarning: '#E5B873',
|
||||||
statusCritical: '#E57373',
|
statusCritical: '#E57373',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Heritage - Legacy Fleet
|
// Heritage - Legacy Fleet
|
||||||
heritage: {
|
heritage: {
|
||||||
background: '#E8F6F8',
|
background: '#E8F6F8',
|
||||||
@@ -86,7 +86,7 @@ export const colors = {
|
|||||||
confirmed: '#6BBF8A',
|
confirmed: '#6BBF8A',
|
||||||
pending: '#E5B873',
|
pending: '#E5B873',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Me - Captain's Quarters
|
// Me - Captain's Quarters
|
||||||
me: {
|
me: {
|
||||||
background: '#E8F6F8',
|
background: '#E8F6F8',
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface VaultAsset {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
isEncrypted: boolean;
|
isEncrypted: boolean;
|
||||||
|
heirEmail?: string;
|
||||||
|
rawData?: any; // For debug logging
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sentinel Types
|
// Sentinel Types
|
||||||
@@ -102,3 +104,13 @@ export interface LoginResponse {
|
|||||||
token_type: string;
|
token_type: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Types
|
||||||
|
export interface AIRole {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
icon: string;
|
||||||
|
iconFamily: string;
|
||||||
|
}
|
||||||
|
|||||||
22
src/utils/async_hooks_mock.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Mock for Node.js async_hooks
|
||||||
|
* Used to fix LangGraph.js compatibility with React Native
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AsyncLocalStorage {
|
||||||
|
disable() { }
|
||||||
|
getStore() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
run(store: any, callback: (...args: any[]) => any, ...args: any[]) {
|
||||||
|
return callback(...args);
|
||||||
|
}
|
||||||
|
exit(callback: (...args: any[]) => any, ...args: any[]) {
|
||||||
|
return callback(...args);
|
||||||
|
}
|
||||||
|
enterWith(store: any) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
AsyncLocalStorage,
|
||||||
|
};
|
||||||
202
src/utils/crypto_core.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import * as bip39 from 'bip39';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
// 定义分片类型:[x坐标, y坐标]
|
||||||
|
export type Share = [bigint, bigint];
|
||||||
|
|
||||||
|
// 定义生成密钥的返回接口
|
||||||
|
export interface VaultKeys {
|
||||||
|
mnemonic: string;
|
||||||
|
entropyHex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SentinelKeyEngine {
|
||||||
|
// 使用第 13 个梅森素数 (2^521 - 1)
|
||||||
|
// readonly 确保不会被修改
|
||||||
|
private readonly PRIME: bigint = 2n ** 521n - 1n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. 生成原始 12 助记词 (Master Key)
|
||||||
|
*/
|
||||||
|
public generateVaultKeys(): VaultKeys {
|
||||||
|
// 生成 128 位强度的助记词 (12 个单词)
|
||||||
|
const mnemonic = bip39.generateMnemonic(128);
|
||||||
|
|
||||||
|
// 将助记词转为 16 进制熵 (Hex String)
|
||||||
|
const entropyHex = bip39.mnemonicToEntropy(mnemonic);
|
||||||
|
|
||||||
|
return { mnemonic, entropyHex };
|
||||||
|
}
|
||||||
|
|
||||||
|
public mnemonicToEntropy(mnemonic: string): string {
|
||||||
|
return bip39.mnemonicToEntropy(mnemonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. SSS (3,2) 门限分片逻辑
|
||||||
|
* @param entropyHex - 16进制字符串 (32字符)
|
||||||
|
*/
|
||||||
|
public splitToShares(entropyHex: string): Share[] {
|
||||||
|
// 将 Hex 熵转换为 BigInt
|
||||||
|
const secretInt = BigInt('0x' + entropyHex);
|
||||||
|
|
||||||
|
// 生成随机系数 a,范围 [0, PRIME-1]
|
||||||
|
const a = this.secureRandomBigInt(this.PRIME);
|
||||||
|
|
||||||
|
// 定义函数 f(x) = (S + a * x) % PRIME
|
||||||
|
const f = (x: number): bigint => {
|
||||||
|
const xBi = BigInt(x);
|
||||||
|
return (secretInt + a * xBi) % this.PRIME;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成 3 个分片: x=1, x=2, x=3
|
||||||
|
const share1: Share = [1n, f(1)]; // 手机分片
|
||||||
|
const share2: Share = [2n, f(2)]; // 云端分片
|
||||||
|
const share3: Share = [3n, f(3)]; // 传承卡分片
|
||||||
|
|
||||||
|
return [share1, share2, share3];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. 恢复逻辑:拉格朗日插值还原
|
||||||
|
* @param shareA - 第一个分片
|
||||||
|
* @param shareB - 第二个分片
|
||||||
|
*/
|
||||||
|
public recoverFromShares(shareA: Share, shareB: Share): string {
|
||||||
|
const [x1, y1] = shareA;
|
||||||
|
const [x2, y2] = shareB;
|
||||||
|
|
||||||
|
// 计算分子: (x2 * y1 - x1 * y2) % PRIME
|
||||||
|
// TS/JS 的 % 运算符对负数返回负数,需修正为正余数
|
||||||
|
let numerator = (x2 * y1 - x1 * y2) % this.PRIME;
|
||||||
|
if (numerator < 0n) numerator += this.PRIME;
|
||||||
|
|
||||||
|
// 计算分母: (x2 - x1)
|
||||||
|
let denominator = (x2 - x1) % this.PRIME;
|
||||||
|
if (denominator < 0n) denominator += this.PRIME;
|
||||||
|
|
||||||
|
// 计算分母的模逆: denominator^-1 mod PRIME
|
||||||
|
// 费马小定理: a^(p-2) = a^-1 (mod p)
|
||||||
|
const invDenominator = this.modPow(denominator, this.PRIME - 2n, this.PRIME);
|
||||||
|
|
||||||
|
// 还原常数项 S
|
||||||
|
const secretInt = (numerator * invDenominator) % this.PRIME;
|
||||||
|
|
||||||
|
// 转回 Hex 字符串
|
||||||
|
let recoveredEntropyHex = secretInt.toString(16);
|
||||||
|
|
||||||
|
// 补齐前导零 (Pad Start)
|
||||||
|
// 128 bit 熵 = 16 字节 = 32 个 Hex 字符
|
||||||
|
// 如果你的熵是 256 bit,这里需要改为 64
|
||||||
|
recoveredEntropyHex = recoveredEntropyHex.padStart(32, '0');
|
||||||
|
|
||||||
|
return bip39.entropyToMnemonic(recoveredEntropyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private Helper Methods ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成小于 limit 的安全随机 BigInt
|
||||||
|
*/
|
||||||
|
private secureRandomBigInt(limit: bigint): bigint {
|
||||||
|
// 计算需要的字节数
|
||||||
|
const bitLength = limit.toString(2).length;
|
||||||
|
const byteLength = Math.ceil(bitLength / 8);
|
||||||
|
|
||||||
|
let randomBi: bigint;
|
||||||
|
do {
|
||||||
|
const buf = crypto.randomBytes(byteLength);
|
||||||
|
randomBi = BigInt('0x' + buf.toString('hex'));
|
||||||
|
// 拒绝采样:确保结果小于 limit
|
||||||
|
} while (randomBi >= limit);
|
||||||
|
|
||||||
|
return randomBi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模幂运算: (base^exp) % modulus
|
||||||
|
* 用于计算模逆
|
||||||
|
*/
|
||||||
|
private modPow(base: bigint, exp: bigint, modulus: bigint): bigint {
|
||||||
|
let result = 1n;
|
||||||
|
base = base % modulus;
|
||||||
|
while (exp > 0n) {
|
||||||
|
if (exp % 2n === 1n) result = (result * base) % modulus;
|
||||||
|
exp = exp >> 1n; // 相当于除以 2
|
||||||
|
base = (base * base) % modulus;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SentinelVault {
|
||||||
|
private salt: Buffer;
|
||||||
|
|
||||||
|
constructor(salt?: string | Buffer) {
|
||||||
|
// 默认盐值与 Python 版本保持一致
|
||||||
|
this.salt = salt ? Buffer.from(salt) : Buffer.from('Sentinel_Salt_2026');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 PBKDF2 将助记词转换为 AES-256 密钥 (32 bytes)
|
||||||
|
*/
|
||||||
|
public async deriveKey(mnemonicPhrase: string): Promise<Buffer> {
|
||||||
|
// 1. BIP-39 助记词转种子 (遵循 BIP-39 标准)
|
||||||
|
// Python 的 to_seed 默认返回 64 字节种子
|
||||||
|
const seed = await bip39.mnemonicToSeed(mnemonicPhrase);
|
||||||
|
|
||||||
|
// 2. PBKDF2 派生密钥
|
||||||
|
// 注意:PyCryptodome 的 PBKDF2 默认使用 HMAC-SHA1 (如未指定)
|
||||||
|
// 为了确保与 Python 逻辑严格一致,这里使用 'sha1'
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.pbkdf2(seed, this.salt, 100000, 32, 'sha1', (err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(derivedKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 AES-256-GCM 模式进行加密
|
||||||
|
*/
|
||||||
|
public encryptData(key: Buffer, plaintext: string): Buffer {
|
||||||
|
// GCM 模式推荐 nonce 长度,Python 默认通常为 16 字节
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 获取 GCM 认证标签 (16 bytes)
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// 拼接结果:Nonce + Tag + Ciphertext
|
||||||
|
return Buffer.concat([iv, tag, ciphertext]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256-GCM 解密
|
||||||
|
*/
|
||||||
|
public decryptData(key: Buffer, encryptedBlob: Buffer): string {
|
||||||
|
try {
|
||||||
|
// 切片提取组件
|
||||||
|
const iv = encryptedBlob.subarray(0, 16);
|
||||||
|
const tag = encryptedBlob.subarray(16, 32);
|
||||||
|
const ciphertext = encryptedBlob.subarray(32);
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(ciphertext),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch (error) {
|
||||||
|
return "【解密失败】:密钥错误或数据被篡改";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/utils/crypto_polyfill.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import * as ExpoCrypto from 'expo-crypto';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
import { pbkdf2 as noblePbkdf2 } from '@noble/hashes/pbkdf2';
|
||||||
|
import { sha1 } from '@noble/hashes/sha1';
|
||||||
|
import { sha256 } from '@noble/hashes/sha256';
|
||||||
|
import { sha512 } from '@noble/hashes/sha512';
|
||||||
|
import { gcm } from '@noble/ciphers/aes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node.js Crypto Polyfill for React Native
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function randomBytes(size: number): Buffer {
|
||||||
|
const bytes = new Uint8Array(size);
|
||||||
|
ExpoCrypto.getRandomValues(bytes);
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashMap: Record<string, any> = {
|
||||||
|
sha1,
|
||||||
|
sha256,
|
||||||
|
sha512,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pbkdf2(
|
||||||
|
password: string | Buffer,
|
||||||
|
salt: string | Buffer,
|
||||||
|
iterations: number,
|
||||||
|
keylen: number,
|
||||||
|
digest: string,
|
||||||
|
callback: (err: Error | null, derivedKey: Buffer) => void
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const passwordBytes = typeof password === 'string' ? Buffer.from(password) : password;
|
||||||
|
const saltBytes = typeof salt === 'string' ? Buffer.from(salt) : salt;
|
||||||
|
const hasher = hashMap[digest.toLowerCase()];
|
||||||
|
|
||||||
|
if (!hasher) {
|
||||||
|
throw new Error(`Unsupported digest: ${digest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = noblePbkdf2(hasher, passwordBytes, saltBytes, {
|
||||||
|
c: iterations,
|
||||||
|
dkLen: keylen,
|
||||||
|
});
|
||||||
|
|
||||||
|
callback(null, Buffer.from(result));
|
||||||
|
} catch (err) {
|
||||||
|
callback(err as Error, Buffer.alloc(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES-GCM Implementation
|
||||||
|
class Cipher {
|
||||||
|
private key: Uint8Array;
|
||||||
|
private iv: Uint8Array;
|
||||||
|
private authTag: Buffer | null = null;
|
||||||
|
private aesGcm: any;
|
||||||
|
private buffer: Buffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
constructor(key: Buffer, iv: Buffer) {
|
||||||
|
this.key = new Uint8Array(key);
|
||||||
|
this.iv = new Uint8Array(iv);
|
||||||
|
// @noble/ciphers/aes gcm takes (key, nonce)
|
||||||
|
this.aesGcm = gcm(this.key, this.iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(data: string | Buffer, inputEncoding?: string): Buffer {
|
||||||
|
const input = typeof data === 'string' ? Buffer.from(data, inputEncoding as any) : data;
|
||||||
|
this.buffer = Buffer.concat([this.buffer, input]);
|
||||||
|
return Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final(): Buffer {
|
||||||
|
const result = this.aesGcm.encrypt(this.buffer);
|
||||||
|
// @noble/ciphers returns ciphertext + tag (16 bytes)
|
||||||
|
const tag = result.slice(-16);
|
||||||
|
const ciphertext = result.slice(0, -16);
|
||||||
|
this.authTag = Buffer.from(tag);
|
||||||
|
return Buffer.from(ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthTag(): Buffer {
|
||||||
|
if (!this.authTag) throw new Error('Ciphers: TAG not available before final()');
|
||||||
|
return this.authTag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Decipher {
|
||||||
|
private key: Uint8Array;
|
||||||
|
private iv: Uint8Array;
|
||||||
|
private tag: Uint8Array | null = null;
|
||||||
|
private aesGcm: any;
|
||||||
|
private buffer: Buffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
constructor(key: Buffer, iv: Buffer) {
|
||||||
|
this.key = new Uint8Array(key);
|
||||||
|
this.iv = new Uint8Array(iv);
|
||||||
|
this.aesGcm = gcm(this.key, this.iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthTag(tag: Buffer): void {
|
||||||
|
this.tag = new Uint8Array(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(data: Buffer): Buffer {
|
||||||
|
this.buffer = Buffer.concat([this.buffer, data]);
|
||||||
|
return Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final(): Buffer {
|
||||||
|
if (!this.tag) throw new Error('Decipher: Auth tag not set');
|
||||||
|
// @noble/ciphers expects ciphertext then tag
|
||||||
|
const full = new Uint8Array(this.buffer.length + this.tag.length);
|
||||||
|
full.set(this.buffer);
|
||||||
|
full.set(this.tag, this.buffer.length);
|
||||||
|
|
||||||
|
const decrypted = this.aesGcm.decrypt(full);
|
||||||
|
return Buffer.from(decrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCipheriv(algorithm: string, key: Buffer, iv: Buffer): Cipher {
|
||||||
|
if (algorithm !== 'aes-256-gcm') {
|
||||||
|
throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`);
|
||||||
|
}
|
||||||
|
return new Cipher(key, iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDecipheriv(algorithm: string, key: Buffer, iv: Buffer): Decipher {
|
||||||
|
if (algorithm !== 'aes-256-gcm') {
|
||||||
|
throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`);
|
||||||
|
}
|
||||||
|
return new Decipher(key, iv);
|
||||||
|
}
|
||||||
6
src/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* Utility functions for Sentinel
|
||||||
|
*/
|
||||||
|
|
||||||
|
export * from './sss';
|
||||||
|
export * from './vaultAssets';
|
||||||
268
src/utils/sss.ts
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
/**
|
||||||
|
* Shamir's Secret Sharing (SSS) Implementation
|
||||||
|
*
|
||||||
|
* This implements a (3,2) threshold scheme where:
|
||||||
|
* - Secret is split into 3 shares
|
||||||
|
* - Any 2 shares can recover the original secret
|
||||||
|
*
|
||||||
|
* Correspondence with crypto_core_demo (Python):
|
||||||
|
* - sp_trust_sharding.py: split_to_shares(), recover_from_shares()
|
||||||
|
* - Same algorithm: f(x) = secret + a*x (mod P), Lagrange interpolation
|
||||||
|
* - Difference: entropy conversion. Python uses BIP-39 (mnemonic.to_entropy);
|
||||||
|
* we use custom word list index-based encoding for compatibility with
|
||||||
|
* existing MNEMONIC_WORDS. SSS split/recover logic is identical.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Use a large prime for the finite field
|
||||||
|
// We use 2^127 - 1 (a Mersenne prime) which fits well in BigInt
|
||||||
|
// This is smaller than the Python version's 2^521 - 1 but sufficient for our 128-bit entropy
|
||||||
|
const PRIME = BigInt('170141183460469231731687303715884105727'); // 2^127 - 1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an SSS share as a coordinate point (x, y)
|
||||||
|
*/
|
||||||
|
export interface SSSShare {
|
||||||
|
x: number;
|
||||||
|
y: bigint;
|
||||||
|
label: string; // 'device' | 'cloud' | 'heir'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a cryptographically secure random BigInt in range [0, max)
|
||||||
|
*/
|
||||||
|
function secureRandomBigInt(max: bigint): bigint {
|
||||||
|
// Get the number of bytes needed
|
||||||
|
const byteLength = Math.ceil(max.toString(2).length / 8);
|
||||||
|
const randomBytes = new Uint8Array(byteLength);
|
||||||
|
|
||||||
|
// Use crypto.getRandomValues for secure randomness
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||||
|
crypto.getRandomValues(randomBytes);
|
||||||
|
} else {
|
||||||
|
// Fallback for environments without crypto
|
||||||
|
for (let i = 0; i < byteLength; i++) {
|
||||||
|
randomBytes[i] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to BigInt
|
||||||
|
let result = BigInt(0);
|
||||||
|
for (let i = 0; i < randomBytes.length; i++) {
|
||||||
|
result = (result << BigInt(8)) + BigInt(randomBytes[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure result is within range
|
||||||
|
return result % max;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert mnemonic words to entropy (as BigInt)
|
||||||
|
* Each word is mapped to its index, then combined into a single large number
|
||||||
|
*/
|
||||||
|
export function mnemonicToEntropy(words: string[], wordList: readonly string[]): bigint {
|
||||||
|
let entropy = BigInt(0);
|
||||||
|
const wordListLength = BigInt(wordList.length);
|
||||||
|
|
||||||
|
for (const word of words) {
|
||||||
|
const index = wordList.indexOf(word);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error(`Word "${word}" not found in word list`);
|
||||||
|
}
|
||||||
|
entropy = entropy * wordListLength + BigInt(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
return entropy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert entropy back to mnemonic words
|
||||||
|
*/
|
||||||
|
export function entropyToMnemonic(entropy: bigint, wordCount: number, wordList: readonly string[]): string[] {
|
||||||
|
const words: string[] = [];
|
||||||
|
const wordListLength = BigInt(wordList.length);
|
||||||
|
let remaining = entropy;
|
||||||
|
|
||||||
|
for (let i = 0; i < wordCount; i++) {
|
||||||
|
const index = Number(remaining % wordListLength);
|
||||||
|
words.unshift(wordList[index]);
|
||||||
|
remaining = remaining / wordListLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
return words;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modular inverse using extended Euclidean algorithm
|
||||||
|
* Returns x such that (a * x) % p === 1
|
||||||
|
*/
|
||||||
|
function modInverse(a: bigint, p: bigint): bigint {
|
||||||
|
let [oldR, r] = [a % p, p];
|
||||||
|
let [oldS, s] = [BigInt(1), BigInt(0)];
|
||||||
|
|
||||||
|
while (r !== BigInt(0)) {
|
||||||
|
const quotient = oldR / r;
|
||||||
|
[oldR, r] = [r, oldR - quotient * r];
|
||||||
|
[oldS, s] = [s, oldS - quotient * s];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure positive result
|
||||||
|
return ((oldS % p) + p) % p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modular arithmetic helper to ensure positive results
|
||||||
|
*/
|
||||||
|
function mod(n: bigint, p: bigint): bigint {
|
||||||
|
return ((n % p) + p) % p;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Split a secret into 3 shares using SSS (3,2) threshold scheme
|
||||||
|
*
|
||||||
|
* Uses linear polynomial: f(x) = secret + a*x (mod p)
|
||||||
|
* where 'a' is a random coefficient
|
||||||
|
*
|
||||||
|
* Any 2 points on this line can recover the y-intercept (secret)
|
||||||
|
*/
|
||||||
|
export function splitSecret(secret: bigint): SSSShare[] {
|
||||||
|
// Generate random coefficient for the polynomial
|
||||||
|
const a = secureRandomBigInt(PRIME);
|
||||||
|
|
||||||
|
// Polynomial: f(x) = secret + a*x (mod PRIME)
|
||||||
|
const f = (x: number): bigint => {
|
||||||
|
return mod(secret + a * BigInt(x), PRIME);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate 3 shares at x = 1, 2, 3
|
||||||
|
return [
|
||||||
|
{ x: 1, y: f(1), label: 'device' },
|
||||||
|
{ x: 2, y: f(2), label: 'cloud' },
|
||||||
|
{ x: 3, y: f(3), label: 'heir' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover the secret from any 2 shares using Lagrange interpolation
|
||||||
|
*
|
||||||
|
* For 2 points (x1, y1) and (x2, y2), the secret (y-intercept) is:
|
||||||
|
* S = (x2*y1 - x1*y2) / (x2 - x1) (mod p)
|
||||||
|
*/
|
||||||
|
export function recoverSecret(shareA: SSSShare, shareB: SSSShare): bigint {
|
||||||
|
const { x: x1, y: y1 } = shareA;
|
||||||
|
const { x: x2, y: y2 } = shareB;
|
||||||
|
|
||||||
|
// Numerator: x2*y1 - x1*y2
|
||||||
|
const numerator = mod(
|
||||||
|
BigInt(x2) * y1 - BigInt(x1) * y2,
|
||||||
|
PRIME
|
||||||
|
);
|
||||||
|
|
||||||
|
// Denominator: x2 - x1
|
||||||
|
const denominator = mod(BigInt(x2 - x1), PRIME);
|
||||||
|
|
||||||
|
// Division in modular arithmetic = multiply by modular inverse
|
||||||
|
const invDenominator = modInverse(denominator, PRIME);
|
||||||
|
|
||||||
|
return mod(numerator * invDenominator, PRIME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a share for display (truncated for readability)
|
||||||
|
* Shows first 8 and last 4 characters of the y-value
|
||||||
|
*/
|
||||||
|
export function formatShareForDisplay(share: SSSShare): string {
|
||||||
|
const yStr = share.y.toString();
|
||||||
|
if (yStr.length <= 16) {
|
||||||
|
return `(${share.x}, ${yStr})`;
|
||||||
|
}
|
||||||
|
return `(${share.x}, ${yStr.slice(0, 8)}...${yStr.slice(-4)})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a share as a compact display string (for UI cards)
|
||||||
|
* Returns a shorter format showing the share index and a hash-like preview
|
||||||
|
*/
|
||||||
|
export function formatShareCompact(share: SSSShare): string {
|
||||||
|
const yStr = share.y.toString();
|
||||||
|
// Create a "fingerprint" from the y value
|
||||||
|
const fingerprint = yStr.slice(0, 4) + '-' + yStr.slice(4, 8) + '-' + yStr.slice(-4);
|
||||||
|
return fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize a share to a string for storage/transmission
|
||||||
|
*/
|
||||||
|
export function serializeShare(share: SSSShare): string {
|
||||||
|
return JSON.stringify({
|
||||||
|
x: share.x,
|
||||||
|
y: share.y.toString(),
|
||||||
|
label: share.label,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize a share from a string
|
||||||
|
*/
|
||||||
|
export function deserializeShare(str: string): SSSShare {
|
||||||
|
const parsed = JSON.parse(str);
|
||||||
|
return {
|
||||||
|
x: parsed.x,
|
||||||
|
y: BigInt(parsed.y),
|
||||||
|
label: parsed.label,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main function to generate mnemonic and SSS shares
|
||||||
|
* This is the entry point for the vault initialization flow
|
||||||
|
*/
|
||||||
|
export interface VaultKeyData {
|
||||||
|
mnemonic: string[];
|
||||||
|
shares: SSSShare[];
|
||||||
|
entropy: bigint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateVaultKeys(
|
||||||
|
wordList: readonly string[],
|
||||||
|
wordCount: number = 12
|
||||||
|
): VaultKeyData {
|
||||||
|
// Generate random mnemonic
|
||||||
|
const mnemonic: string[] = [];
|
||||||
|
for (let i = 0; i < wordCount; i++) {
|
||||||
|
const index = Math.floor(Math.random() * wordList.length);
|
||||||
|
mnemonic.push(wordList[index]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to entropy
|
||||||
|
const entropy = mnemonicToEntropy(mnemonic, wordList);
|
||||||
|
|
||||||
|
// Split into shares
|
||||||
|
const shares = splitSecret(entropy);
|
||||||
|
|
||||||
|
return { mnemonic, shares, entropy };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify that shares can recover the original entropy
|
||||||
|
* Useful for testing and validation
|
||||||
|
*/
|
||||||
|
export function verifyShares(
|
||||||
|
shares: SSSShare[],
|
||||||
|
originalEntropy: bigint
|
||||||
|
): boolean {
|
||||||
|
// Test all 3 combinations of 2 shares
|
||||||
|
const combinations = [
|
||||||
|
[shares[0], shares[1]], // Device + Cloud
|
||||||
|
[shares[1], shares[2]], // Cloud + Heir
|
||||||
|
[shares[0], shares[2]], // Device + Heir
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [a, b] of combinations) {
|
||||||
|
const recovered = recoverSecret(a, b);
|
||||||
|
if (recovered !== originalEntropy) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
76
src/utils/token_utils.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Token Utilities
|
||||||
|
*
|
||||||
|
* Shared logic for trimming messages to stay within token limits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseMessage, SystemMessage } from "@langchain/core/messages";
|
||||||
|
import { AIMessage as ServiceAIMessage } from "../services/ai.service";
|
||||||
|
|
||||||
|
export const TOKEN_LIMIT = 10000;
|
||||||
|
const CHARS_PER_TOKEN = 3; // Conservative estimate: 1 token ≈ 3 chars
|
||||||
|
export const MAX_CHARS = TOKEN_LIMIT * CHARS_PER_TOKEN;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims LangChain messages to fit within token limit
|
||||||
|
*/
|
||||||
|
export function trimLangChainMessages(messages: BaseMessage[]): BaseMessage[] {
|
||||||
|
let totalLength = 0;
|
||||||
|
const trimmed: BaseMessage[] = [];
|
||||||
|
|
||||||
|
// Always keep the system message if it's at the start
|
||||||
|
let systemMsg: BaseMessage | null = null;
|
||||||
|
if (messages.length > 0 && (messages[0] instanceof SystemMessage || (messages[0] as any)._getType?.() === 'system')) {
|
||||||
|
systemMsg = messages[0];
|
||||||
|
totalLength += systemMsg.content.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate backwards and add messages until we hit the char limit
|
||||||
|
for (let i = messages.length - 1; i >= (systemMsg ? 1 : 0); i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
const len = msg.content.toString().length;
|
||||||
|
|
||||||
|
if (totalLength + len > MAX_CHARS) break;
|
||||||
|
|
||||||
|
trimmed.unshift(msg);
|
||||||
|
totalLength += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemMsg) {
|
||||||
|
trimmed.unshift(systemMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims internal AIMessage format messages to fit within token limit
|
||||||
|
*/
|
||||||
|
export function trimInternalMessages(messages: ServiceAIMessage[]): ServiceAIMessage[] {
|
||||||
|
let totalLength = 0;
|
||||||
|
const trimmed: ServiceAIMessage[] = [];
|
||||||
|
|
||||||
|
// Always keep the system message if it's at the start
|
||||||
|
let systemMsg: ServiceAIMessage | null = null;
|
||||||
|
if (messages.length > 0 && messages[0].role === 'system') {
|
||||||
|
systemMsg = messages[0];
|
||||||
|
totalLength += systemMsg.content.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate backwards and add messages until we hit the char limit
|
||||||
|
for (let i = messages.length - 1; i >= (systemMsg ? 1 : 0); i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
const len = msg.content.length;
|
||||||
|
|
||||||
|
if (totalLength + len > MAX_CHARS) break;
|
||||||
|
|
||||||
|
trimmed.unshift(msg);
|
||||||
|
totalLength += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemMsg) {
|
||||||
|
trimmed.unshift(systemMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
71
src/utils/vaultAssets.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Vault assets: API ↔ UI mapping and initial mock data.
|
||||||
|
* Used by useVaultAssets and VaultScreen for /assets/get and /assets/create flows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { VaultAsset, VaultAssetType } from '../types';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Shape returned by GET /assets/get (backend AssetOut) */
|
||||||
|
export interface ApiAsset {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
type?: string;
|
||||||
|
author_id?: number;
|
||||||
|
private_key_shard?: string;
|
||||||
|
content_outer_encrypted?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
heir_id?: number;
|
||||||
|
heir_email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const VAULT_ASSET_TYPES: VaultAssetType[] = [
|
||||||
|
'game_account',
|
||||||
|
'private_key',
|
||||||
|
'document',
|
||||||
|
'photo',
|
||||||
|
'will',
|
||||||
|
'custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const initialVaultAssets: VaultAsset[] = [];
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Mapping
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map backend API asset to VaultAsset for UI.
|
||||||
|
*/
|
||||||
|
export function mapApiAssetToVaultAsset(api: ApiAsset): VaultAsset {
|
||||||
|
const type: VaultAssetType =
|
||||||
|
api.type && VAULT_ASSET_TYPES.includes(api.type as VaultAssetType)
|
||||||
|
? (api.type as VaultAssetType)
|
||||||
|
: 'custom';
|
||||||
|
return {
|
||||||
|
id: String(api.id),
|
||||||
|
type,
|
||||||
|
label: api.title,
|
||||||
|
createdAt: api.created_at ? new Date(api.created_at) : new Date(),
|
||||||
|
updatedAt: api.updated_at ? new Date(api.updated_at) : new Date(),
|
||||||
|
isEncrypted: true,
|
||||||
|
heirEmail: api.heir_email,
|
||||||
|
rawData: api,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map array of API assets to VaultAsset[].
|
||||||
|
*/
|
||||||
|
export function mapApiAssetsToVaultAssets(apiList: ApiAsset[]): VaultAsset[] {
|
||||||
|
return apiList.map(mapApiAssetToVaultAsset);
|
||||||
|
}
|
||||||
|
|
||||||
107
src/utils/vaultCrypto.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Vault crypto: PBKDF2 key derivation + AES-256-GCM encrypt/decrypt.
|
||||||
|
* Matches backend SentinelVault semantics (PBKDF2 from mnemonic, AES-GCM).
|
||||||
|
* Uses Web Crypto API (crypto.subtle). Requires secure context / React Native polyfill if needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SALT = new TextEncoder().encode('Sentinel_Salt_2026');
|
||||||
|
const PBKDF2_ITERATIONS = 100000;
|
||||||
|
const AES_KEY_LEN = 256;
|
||||||
|
const GCM_IV_LEN = 16;
|
||||||
|
const GCM_TAG_LEN = 16;
|
||||||
|
|
||||||
|
function getCrypto(): Crypto {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.subtle) return crypto;
|
||||||
|
throw new Error('vaultCrypto: crypto.subtle not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte AES key from mnemonic phrase (space-separated words).
|
||||||
|
*/
|
||||||
|
export async function deriveKey(mnemonicPhrase: string, salt: Uint8Array = SALT): Promise<ArrayBuffer> {
|
||||||
|
const crypto = getCrypto();
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(mnemonicPhrase),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveBits']
|
||||||
|
);
|
||||||
|
const saltBuf = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer;
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: saltBuf,
|
||||||
|
iterations: PBKDF2_ITERATIONS,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
AES_KEY_LEN
|
||||||
|
);
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt plaintext with AES-256-GCM. Returns nonce(16) + tag(16) + ciphertext (matches Python SentinelVault).
|
||||||
|
*/
|
||||||
|
export async function encryptDataGCM(key: ArrayBuffer, plaintext: string): Promise<Uint8Array> {
|
||||||
|
const crypto = getCrypto();
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(GCM_IV_LEN));
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
key,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
const encoded = new TextEncoder().encode(plaintext);
|
||||||
|
const ciphertextWithTag = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv, tagLength: GCM_TAG_LEN * 8 },
|
||||||
|
cryptoKey,
|
||||||
|
encoded
|
||||||
|
);
|
||||||
|
const out = new Uint8Array(iv.length + ciphertextWithTag.byteLength);
|
||||||
|
out.set(iv, 0);
|
||||||
|
out.set(new Uint8Array(ciphertextWithTag), iv.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt blob from encryptDataGCM (nonce(16) + ciphertext+tag).
|
||||||
|
*/
|
||||||
|
export async function decryptDataGCM(key: ArrayBuffer, blob: Uint8Array): Promise<string> {
|
||||||
|
const crypto = getCrypto();
|
||||||
|
const iv = blob.subarray(0, GCM_IV_LEN);
|
||||||
|
const ciphertextWithTag = blob.subarray(GCM_IV_LEN);
|
||||||
|
const ivBuf = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer;
|
||||||
|
const ctBuf = ciphertextWithTag.buffer.slice(
|
||||||
|
ciphertextWithTag.byteOffset,
|
||||||
|
ciphertextWithTag.byteOffset + ciphertextWithTag.byteLength
|
||||||
|
) as ArrayBuffer;
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
key,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
const dec = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: ivBuf, tagLength: GCM_TAG_LEN * 8 },
|
||||||
|
cryptoKey,
|
||||||
|
ctBuf
|
||||||
|
);
|
||||||
|
return new TextDecoder().decode(dec);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const len = hex.length / 2;
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
return out;
|
||||||
|
}
|
||||||