22 Commits

Author SHA1 Message Date
Ada
1e6c06bfef Merge branch 'main' into mobile-demo 2026-02-04 17:23:25 -08:00
Ada
8994a3e045 feat(flow): image attachment and 422 error message display
- Attach image then send with optional text or image-only (default prompt)
- Attached image preview above input with remove
- AI image API error detail no longer shows [object Object]
2026-02-04 17:19:51 -08:00
Ada
d44ccc3ace feat(flow): add interactive AI puppet to FlowScreen
- Added puppet component modules: PuppetView, FlowPuppetSlot, and type definitions

- The puppet supports actions such as idle/smile/jump/shake/think, with a default smile.

- FlowScreen integrates puppet slots; it automatically uses the "think" function when sending messages and allows for interactive actions like Smile/Jump/Shake.

- The code is independent of the existing chat logic and does not affect existing functionality.
2026-02-04 16:57:28 -08:00
Ada
e33ea62e35 fix(mobile): polyfill
- polyfill crypto.getRandomValues (uuid/LangChain  not supported))
- polyfill AbortSignal.prototype.throwIfAborted (throwIfAborted is not a function)")
2026-02-04 16:48:00 -08:00
Ada
96d95a50fc fix: resolve ReadableStream in simulator so LangGraph runs on RN
- Add src/polyfills.ts as the first executed module; inject ReadableStream/WritableStream/TransformStream via web-streams-polyfill and ponyfill fallback
- Import polyfills at the top of App.tsx so globals are set before any LangChain/LangGraph code loads
- Add assets/images/icon.png to fix Metro “Asset not found” for icon
2026-02-04 15:24:14 -08:00
lusixing
c1ce804d14 langgraph_used 2026-02-03 21:37:41 -08:00
lusixing
0aab9a838b ai_role_update 2026-02-02 22:20:24 -08:00
lusixing
6822638d47 complete_heir_functions 2026-02-02 19:40:49 -08:00
lusixing
5c1172a912 added_reveal_secret_and_delete_treasure 2026-02-02 17:34:03 -08:00
lusixing
b5373c2d9a update_260201-3 2026-02-01 21:13:15 -08:00
Ada
3ffcc60ee8 feat(vault): vault storage (user-isolated, multi-account) 2026-02-01 15:22:32 -08:00
Ada
50e78c84c9 feat(vault): vault storage (user-isolated, multi-account) 2026-02-01 11:57:16 -08:00
Ada
8e6c621f7b feat(vault): show mnemonic flow will show at first time ; or user reset vault state; 2026-02-01 11:02:14 -08:00
Ada
7b8511f080 fix(bug): Could not find MIME for Buffer <null> 2026-02-01 10:36:21 -08:00
Ada
f6fa19d0b2 feat(vault): add get/create assets API in workflow
TODO: update vault.service.ts. Use MNEMONIC workflow to create real private_key_shard and content_inner_encrypted
2026-02-01 09:19:45 -08:00
Ada
536513ab3f fix(bug): Could not find MIME for Buffer <null> 2026-02-01 09:12:29 -08:00
Ada
240a7eea8b Merge branch 'Steven' 2026-01-31 21:21:55 -08:00
lusixing
22dc3abf65 Merge branch 'multi_prompt_local_storage' 2026-01-31 11:13:41 -08:00
lusixing
ed1f6fc49d multi_prompt_local_storage 2026-01-31 11:11:25 -08:00
Ada
218b2e8b29 add vault first time control 2026-01-30 21:26:38 -08:00
Ada
fb1377eb4b Generate SSS shares from mnemonic words 2026-01-30 16:31:09 -08:00
Ada
c07f1f20d5 fix the warning 2026-01-30 15:22:13 -08:00
40 changed files with 5408 additions and 845 deletions

View File

@@ -4,6 +4,7 @@
* Main application component with authentication routing.
* Shows loading screen while restoring auth state.
*/
import './src/polyfills';
import React from 'react';
import { Buffer } from 'buffer';

View File

@@ -19,14 +19,10 @@
"bundleIdentifier": "com.sentinel.app"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#459E9E"
},
"package": "com.sentinel.app"
},
"web": {
"favicon": "./assets/favicon.png",
"favicon": "./assets/icon.png",
"bundler": "metro"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 B

After

Width:  |  Height:  |  Size: 70 B

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 B

After

Width:  |  Height:  |  Size: 70 B

15
metro.config.js Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,10 @@
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@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-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18",
@@ -20,6 +24,7 @@
"expo": "~52.0.0",
"expo-asset": "~11.0.5",
"expo-constants": "~17.0.8",
"expo-crypto": "~14.0.2",
"expo-font": "~13.0.4",
"expo-haptics": "~14.0.0",
"expo-image-picker": "^17.0.10",
@@ -29,11 +34,14 @@
"react-dom": "18.3.1",
"react-native": "^0.76.9",
"react-native-gesture-handler": "~2.20.2",
"react-native-view-shot": "^3.8.0",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.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": {
"@babel/core": "^7.25.2",

View File

@@ -36,7 +36,7 @@ export default function BiometricModal({
if (visible) {
setIsScanning(false);
scanAnimation.setValue(0);
// Pulse animation
Animated.loop(
Animated.sequence([
@@ -57,32 +57,30 @@ export default function BiometricModal({
const handleScan = () => {
setIsScanning(true);
Animated.loop(
Animated.sequence([
Animated.timing(scanAnimation, {
toValue: 1,
duration: 800,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(scanAnimation, {
toValue: 0,
duration: 800,
duration: 400,
useNativeDriver: true,
}),
]),
{ iterations: 2 }
{ iterations: 1 }
).start(() => {
setTimeout(() => {
onSuccess();
}, 300);
onSuccess();
});
};
const backgroundColor = isDark ? colors.vault.cardBackground : colors.white;
const textColor = isDark ? colors.vault.text : colors.nautical.navy;
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.nautical.teal, colors.nautical.seafoam];
@@ -97,10 +95,10 @@ export default function BiometricModal({
<View style={[styles.container, { backgroundColor }, shadows.medium]}>
{/* Ship wheel watermark */}
<View style={styles.watermark}>
<MaterialCommunityIcons
name="ship-wheel"
size={150}
color={isDark ? colors.vault.primary : colors.nautical.lightMint}
<MaterialCommunityIcons
name="ship-wheel"
size={150}
color={isDark ? colors.vault.primary : colors.nautical.lightMint}
style={{ opacity: 0.15 }}
/>
</View>
@@ -109,7 +107,7 @@ export default function BiometricModal({
<Text style={[styles.message, { color: isDark ? colors.vault.textSecondary : colors.nautical.sage }]}>
{message}
</Text>
<TouchableOpacity
style={styles.fingerprintButton}
onPress={handleScan}
@@ -147,10 +145,10 @@ export default function BiometricModal({
colors={accentGradient}
style={styles.fingerprintGradient}
>
<Ionicons
name={isScanning ? "finger-print" : "finger-print-outline"}
size={48}
color="#fff"
<Ionicons
name={isScanning ? "finger-print" : "finger-print-outline"}
size={48}
color="#fff"
/>
</LinearGradient>
</Animated.View>

View 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',
},
});

View 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,
},
});

View File

@@ -0,0 +1,3 @@
export { PuppetView } from './PuppetView';
export { FlowPuppetSlot } from './FlowPuppetSlot';
export type { PuppetAction, PuppetState, PuppetViewProps, FlowPuppetSlotProps } from './types';

View 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;
}

View File

@@ -51,11 +51,13 @@ export const API_ENDPOINTS = {
CREATE: '/assets/create',
CLAIM: '/assets/claim',
ASSIGN: '/assets/assign',
DELETE: '/assets/delete',
},
// AI Services
AI: {
PROXY: '/ai/proxy',
GET_ROLES: '/get_ai_roles',
},
// Admin Operations
@@ -64,6 +66,48 @@ export const API_ENDPOINTS = {
},
} 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
// =============================================================================
@@ -138,6 +182,44 @@ export const AI_CONFIG = {
* Mock response delay in milliseconds (for NO_BACKEND_MODE)
*/
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;
// =============================================================================

View File

@@ -7,8 +7,10 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
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 { aiService } from '../services/ai.service';
import { storageService } from '../services/storage.service';
// =============================================================================
// Type Definitions
@@ -17,11 +19,13 @@ import { authService } from '../services/auth.service';
interface AuthContextType {
user: User | null;
token: string | null;
aiRoles: AIRole[];
isLoading: boolean;
isInitializing: boolean;
signIn: (credentials: LoginRequest) => Promise<void>;
signUp: (data: RegisterRequest) => Promise<void>;
signOut: () => void;
refreshAIRoles: () => Promise<void>;
}
// Storage keys
@@ -43,6 +47,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [token, setToken] = useState<string | null>(null);
const [aiRoles, setAIRoles] = useState<AIRole[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isInitializing, setIsInitializing] = useState(true);
@@ -65,6 +70,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(storedToken);
setUser(JSON.parse(storedUser));
console.log('[Auth] Restored session for user:', JSON.parse(storedUser).username);
// Fetch AI roles after restoring session
fetchAIRoles(storedToken);
}
} catch (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
*/
@@ -113,6 +143,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(response.access_token);
setUser(response.user);
await saveAuth(response.access_token, response.user);
// Fetch AI roles immediately after login
await fetchAIRoles(response.access_token);
} catch (error) {
throw error;
} 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 = () => {
setUser(null);
setToken(null);
setAIRoles([]);
clearAuth();
//storageService.clearAllData();
};
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
<AuthContext.Provider
value={{
user,
token,
aiRoles,
isLoading,
isInitializing,
signIn,
signUp,
signOut
signIn,
signUp,
signOut,
refreshAIRoles
}}
>
{children}

6
src/hooks/index.ts Normal file
View 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
View 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
View 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;
}
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,11 @@ import {
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useAuth } from '../context/AuthContext';
import { Heir, HeirStatus, PaymentStrategy } from '../types';
import HeritageScreen from './HeritageScreen';
import { getVaultStorageKeys } from '../config';
// Mock heirs data
const initialHeirs: Heir[] = [
@@ -220,7 +222,8 @@ export default function MeScreen() {
const [showHeritageModal, setShowHeritageModal] = useState(false);
const [showThemeModal, setShowThemeModal] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
// Heritage / Fleet Legacy states
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
const [showAddHeirModal, setShowAddHeirModal] = useState(false);
@@ -245,6 +248,7 @@ export default function MeScreen() {
});
const [sanctumArchive, setSanctumArchive] = useState<'off' | 'standard' | 'strict'>('standard');
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 [triggerGraceDays, setTriggerGraceDays] = useState(15);
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
@@ -294,18 +298,40 @@ export default function MeScreen() {
};
const handleAbandonIsland = () => {
Alert.alert(
'Sign Out',
'Are you sure you want to sign out?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign Out',
style: 'destructive',
onPress: signOut
},
]
);
console.log('[MeScreen] Sign out button clicked');
setShowSignOutModal(true);
};
const handleConfirmSignOut = () => {
console.log('[MeScreen] User confirmed sign out');
setShowSignOutModal(false);
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 (
@@ -618,10 +644,10 @@ export default function MeScreen() {
activeOpacity={0.85}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
<Ionicons
name={isDarkMode ? 'moon' : 'sunny'}
size={18}
color={colors.me.primary}
<Ionicons
name={isDarkMode ? 'moon' : 'sunny'}
size={18}
color={colors.me.primary}
/>
<Text style={styles.sanctumText}>Dark Mode</Text>
</View>
@@ -749,7 +775,7 @@ export default function MeScreen() {
visible={showSanctumModal}
animationType="fade"
transparent
onRequestClose={() => setShowSanctumModal(false)}
onRequestClose={handleCloseSanctumModal}
>
<View style={styles.spiritOverlay}>
<View style={styles.spiritModal}>
@@ -885,12 +911,51 @@ export default function MeScreen() {
<Text style={styles.sanctumValue}>View</Text>
</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>
<View style={styles.tideModalButtons}>
<TouchableOpacity
style={styles.confirmPulseButton}
activeOpacity={0.85}
onPress={() => setShowSanctumModal(false)}
onPress={handleCloseSanctumModal}
>
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
<Text style={styles.confirmPulseText}>Save</Text>
@@ -898,7 +963,7 @@ export default function MeScreen() {
<TouchableOpacity
style={styles.confirmPulseButton}
activeOpacity={0.85}
onPress={() => setShowSanctumModal(false)}
onPress={handleCloseSanctumModal}
>
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
<Text style={styles.confirmPulseText}>Close</Text>
@@ -1412,6 +1477,46 @@ export default function MeScreen() {
</View>
</View>
</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>
);
}
@@ -1814,6 +1919,23 @@ const styles = StyleSheet.create({
fontSize: typography.fontSize.sm,
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: {
flexDirection: 'row',
alignItems: 'center',
@@ -1827,6 +1949,34 @@ const styles = StyleSheet.create({
fontSize: typography.fontSize.sm,
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: {
flexDirection: 'row',
alignItems: 'center',
@@ -2333,4 +2483,71 @@ const styles = StyleSheet.create({
justifyContent: 'center',
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',
},
});

View File

@@ -15,11 +15,22 @@ import { colors, typography, spacing, borderRadius, shadows } from '../theme/col
import { SystemStatus, KillSwitchLog } from '../types';
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
const statusConfig: Record<SystemStatus, {
color: string;
label: string;
icon: string;
const statusConfig: Record<SystemStatus, {
color: string;
label: string;
icon: StatusIconName;
description: string;
gradientColors: [string, string];
}> = {
@@ -48,28 +59,14 @@ const statusConfig: Record<SystemStatus, {
// Mock data
const initialLogs: KillSwitchLog[] = [
{
id: '1',
action: 'HEARTBEAT_CONFIRMED',
timestamp: new Date('2024-01-18T09:30: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'),
},
{ id: '1', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-18T09:30: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() {
const [status, setStatus] = useState<SystemStatus>('normal');
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
@@ -81,104 +78,98 @@ export default function SentinelScreen() {
const [showVault, setShowVault] = useState(false);
useEffect(() => {
// Pulse animation
Animated.loop(
const pulseAnimation = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: 1200,
duration: ANIMATION_DURATION.pulse,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1200,
duration: ANIMATION_DURATION.pulse,
useNativeDriver: true,
}),
])
).start();
);
pulseAnimation.start();
// Glow animation
Animated.loop(
const glowAnimation = Animated.loop(
Animated.sequence([
Animated.timing(glowAnim, {
toValue: 1,
duration: 1500,
duration: ANIMATION_DURATION.glow,
useNativeDriver: true,
}),
Animated.timing(glowAnim, {
toValue: 0.5,
duration: 1500,
duration: ANIMATION_DURATION.glow,
useNativeDriver: true,
}),
])
).start();
);
glowAnimation.start();
// Slow rotate for ship wheel
Animated.loop(
const rotateAnimation = Animated.loop(
Animated.timing(rotateAnim, {
toValue: 1,
duration: 30000,
duration: ANIMATION_DURATION.rotate,
useNativeDriver: true,
})
).start();
}, []);
);
rotateAnimation.start();
const openVault = () => {
setShowVault(true);
};
return () => {
pulseAnimation.stop();
glowAnimation.stop();
rotateAnimation.stop();
};
}, [pulseAnim, glowAnim, rotateAnim]);
const openVault = () => setShowVault(true);
const handleHeartbeat = () => {
// Animate pulse
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.15,
duration: 150,
duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 150,
duration: ANIMATION_DURATION.heartbeatPress,
useNativeDriver: true,
}),
]).start();
// Add new log
const newLog: KillSwitchLog = {
id: Date.now().toString(),
action: 'HEARTBEAT_CONFIRMED',
timestamp: new Date(),
};
setLogs([newLog, ...logs]);
// Reset status if warning
setLogs((prevLogs) => [newLog, ...prevLogs]);
if (status === 'warning') {
setStatus('normal');
}
};
const formatDateTime = (date: Date) => {
return date.toLocaleString('en-US', {
const formatDateTime = (date: Date) =>
date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
};
const formatTimeAgo = (date: Date) => {
const now = new Date();
const diff = now.getTime() - date.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `${days} days ago`;
}
if (hours > 0) {
return `${hours}h ${minutes}m ago`;
}
if (hours > 24) return `${Math.floor(hours / 24)} days ago`;
if (hours > 0) return `${hours}h ${minutes}m ago`;
return `${minutes}m ago`;
};
@@ -195,7 +186,7 @@ export default function SentinelScreen() {
style={styles.gradient}
>
<SafeAreaView style={styles.safeArea}>
<ScrollView
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.scrollContent}
@@ -211,14 +202,14 @@ export default function SentinelScreen() {
{/* Status Display */}
<View style={styles.statusContainer}>
<Animated.View
<Animated.View
style={[
styles.statusCircleOuter,
{
{
transform: [{ scale: pulseAnim }],
opacity: glowAnim,
backgroundColor: `${currentStatus.color}20`,
}
},
]}
/>
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
@@ -226,7 +217,7 @@ export default function SentinelScreen() {
colors={currentStatus.gradientColors}
style={styles.statusCircle}
>
<Ionicons name={currentStatus.icon as any} size={56} color="#fff" />
<Ionicons name={currentStatus.icon} size={56} color="#fff" />
</LinearGradient>
</Animated.View>
<Text style={[styles.statusLabel, { color: currentStatus.color }]}>
@@ -240,10 +231,10 @@ export default function SentinelScreen() {
{/* Ship Wheel Watermark */}
<View style={styles.wheelWatermark}>
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<MaterialCommunityIcons
name="ship-wheel"
size={200}
color={colors.sentinel.primary}
<MaterialCommunityIcons
name="ship-wheel"
size={200}
color={colors.sentinel.primary}
style={{ opacity: 0.03 }}
/>
</Animated.View>
@@ -256,24 +247,16 @@ export default function SentinelScreen() {
<FontAwesome5 name="anchor" size={16} color={colors.sentinel.primary} />
</View>
<Text style={styles.metricLabel}>SUBSCRIPTION</Text>
<Text style={styles.metricValue}>
{formatTimeAgo(lastSubscriptionCheck)}
</Text>
<Text style={styles.metricTime}>
{formatDateTime(lastSubscriptionCheck)}
</Text>
<Text style={styles.metricValue}>{formatTimeAgo(lastSubscriptionCheck)}</Text>
<Text style={styles.metricTime}>{formatDateTime(lastSubscriptionCheck)}</Text>
</View>
<View style={styles.metricCard}>
<View style={styles.metricIconContainer}>
<Feather name="edit-3" size={16} color={colors.sentinel.primary} />
</View>
<Text style={styles.metricLabel}>LAST JOURNAL</Text>
<Text style={styles.metricValue}>
{formatTimeAgo(lastFlowActivity)}
</Text>
<Text style={styles.metricTime}>
{formatDateTime(lastFlowActivity)}
</Text>
<Text style={styles.metricValue}>{formatTimeAgo(lastFlowActivity)}</Text>
<Text style={styles.metricTime}>{formatDateTime(lastFlowActivity)}</Text>
</View>
</View>
@@ -292,6 +275,8 @@ export default function SentinelScreen() {
style={styles.vaultAccessButton}
onPress={openVault}
activeOpacity={0.8}
accessibilityLabel="Open Shadow Vault"
accessibilityRole="button"
>
<Text style={styles.vaultAccessButtonText}>Open</Text>
</TouchableOpacity>
@@ -302,6 +287,8 @@ export default function SentinelScreen() {
style={styles.heartbeatButton}
onPress={handleHeartbeat}
activeOpacity={0.9}
accessibilityLabel="Signal the watch - Confirm your presence"
accessibilityRole="button"
>
<LinearGradient
colors={[colors.nautical.teal, colors.nautical.seafoam]}
@@ -330,9 +317,7 @@ export default function SentinelScreen() {
<View style={styles.logDot} />
<View style={styles.logContent}>
<Text style={styles.logAction}>{log.action}</Text>
<Text style={styles.logTime}>
{formatDateTime(log.timestamp)}
</Text>
<Text style={styles.logTime}>{formatDateTime(log.timestamp)}</Text>
</View>
</View>
))}
@@ -353,36 +338,27 @@ export default function SentinelScreen() {
style={styles.vaultCloseButton}
onPress={() => setShowVault(false)}
activeOpacity={0.85}
accessibilityLabel="Close vault"
accessibilityRole="button"
>
<Ionicons name="close" size={20} color={colors.nautical.cream} />
</TouchableOpacity>
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
gradient: {
flex: 1,
},
safeArea: {
flex: 1,
},
scrollView: {
flex: 1,
},
container: { flex: 1 },
gradient: { flex: 1 },
safeArea: { flex: 1 },
scrollView: { flex: 1 },
scrollContent: {
padding: spacing.lg,
paddingBottom: 120,
},
header: {
marginBottom: spacing.xl,
},
header: { marginBottom: spacing.xl },
headerTitleRow: {
flexDirection: 'row',
alignItems: 'center',
@@ -488,9 +464,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.xl,
...shadows.medium,
},
heartbeatGradient: {
padding: spacing.lg,
},
heartbeatGradient: { padding: spacing.lg },
heartbeatContent: {
flexDirection: 'row',
alignItems: 'center',
@@ -544,9 +518,7 @@ const styles = StyleSheet.create({
marginTop: 6,
marginRight: spacing.md,
},
logContent: {
flex: 1,
},
logContent: { flex: 1 },
logAction: {
fontSize: typography.fontSize.sm,
color: colors.sentinel.text,
@@ -559,7 +531,6 @@ const styles = StyleSheet.create({
color: colors.sentinel.textSecondary,
fontFamily: typography.fontFamily.mono,
},
// Shadow Vault Access Card
vaultAccessCard: {
flexDirection: 'row',
alignItems: 'center',
@@ -579,9 +550,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
marginRight: spacing.md,
},
vaultAccessContent: {
flex: 1,
},
vaultAccessContent: { flex: 1 },
vaultAccessTitle: {
fontSize: typography.fontSize.base,
fontWeight: '600',
@@ -603,7 +572,6 @@ const styles = StyleSheet.create({
fontWeight: '700',
fontSize: typography.fontSize.sm,
},
// Vault Modal
vaultModalContainer: {
flex: 1,
backgroundColor: colors.vault.background,

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,8 @@ import {
getApiHeaders,
logApiDebug,
} from '../config';
import { AIRole } from '../types';
import { trimInternalMessages } from '../utils/token_utils';
// =============================================================================
// Type Definitions
@@ -95,7 +97,7 @@ export const aiService = {
}
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
logApiDebug('AI Request', {
url,
hasToken: !!token,
@@ -114,7 +116,7 @@ export const aiService = {
if (!response.ok) {
const errorText = await response.text();
logApiDebug('AI Error Response', errorText);
let errorDetail = 'AI request failed';
try {
const errorData = JSON.parse(errorText);
@@ -131,7 +133,7 @@ export const aiService = {
model: data.model,
choicesCount: data.choices?.length,
});
return data;
} catch (error) {
console.error('AI proxy error:', error);
@@ -143,13 +145,14 @@ export const aiService = {
* Simple helper for single message chat
* @param content - User message content
* @param token - JWT token for authentication
* @param systemPrompt - Optional custom system prompt
* @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[] = [
{
role: 'system',
content: AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
content: systemPrompt || AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
},
{
role: 'user',
@@ -179,7 +182,7 @@ export const aiService = {
}
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
logApiDebug('AI Image Request', {
url,
hasToken: !!token,
@@ -217,11 +220,14 @@ export const aiService = {
if (!response.ok) {
const errorText = await response.text();
logApiDebug('AI Image Error Response', errorText);
let errorDetail = 'AI image request failed';
let errorDetail: string = 'AI image request failed';
try {
const errorData = JSON.parse(errorText);
errorDetail = errorData.detail || errorDetail;
const d = errorData.detail;
if (typeof d === 'string') errorDetail = d;
else if (Array.isArray(d) && d[0]?.msg) errorDetail = d.map((e: { msg?: string }) => e.msg).join('; ');
else if (d && typeof d === 'object') errorDetail = JSON.stringify(d);
} catch {
errorDetail = errorText || errorDetail;
}
@@ -233,11 +239,93 @@ export const aiService = {
id: data.id,
model: data.model,
});
return data.choices[0]?.message?.content || 'No response';
} catch (error) {
console.error('AI image proxy error:', 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];
}
},
};

View File

@@ -23,6 +23,7 @@ export interface Asset {
author_id: number;
private_key_shard: string;
content_outer_encrypted: string;
heir_email?: string;
}
export interface AssetCreate {
@@ -45,7 +46,7 @@ export interface AssetClaimResponse {
export interface AssetAssign {
asset_id: number;
heir_name: string;
heir_email: string;
}
// =============================================================================
@@ -59,6 +60,7 @@ const MOCK_ASSETS: Asset[] = [
author_id: MOCK_CONFIG.USER.id,
private_key_shard: 'mock_shard_1',
content_outer_encrypted: 'mock_encrypted_content_1',
heir_email: 'heir@example.com',
},
{
id: 2,
@@ -142,11 +144,16 @@ export const assetsService = {
body: JSON.stringify(asset),
});
logApiDebug('Create Asset Response Status', response.status);
const responseStatus = response.status;
logApiDebug('Create Asset Response Status', responseStatus);
if (!response.ok) {
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();
@@ -212,7 +219,7 @@ export const assetsService = {
logApiDebug('Assign Asset', 'Using mock mode');
return new Promise((resolve) => {
setTimeout(() => {
resolve({ message: `Asset assigned to ${assignment.heir_name}` });
resolve({ message: `Asset assigned to ${assignment.heir_email}` });
}, MOCK_CONFIG.RESPONSE_DELAY);
});
}
@@ -240,4 +247,44 @@ export const assetsService = {
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;
}
},
};

View File

@@ -23,3 +23,9 @@ export {
type DeclareGualeRequest,
type DeclareGualeResponse
} from './admin.service';
export {
createVaultPayload,
createAssetPayload,
type CreateVaultPayloadResult,
type CreateAssetPayloadResult,
} from './vault.service';

View 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();
}
};

View 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;
}
}
};

View 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_encryptedhex 字符串)
*
* 使用方式:在任意页面调用 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_encryptedAES-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,
};
}

View File

@@ -5,7 +5,7 @@ export const colors = {
// Base colors
white: '#FFFFFF',
black: '#1A2F3A',
// Nautical palette
nautical: {
deepTeal: '#1B4D5C',
@@ -21,7 +21,7 @@ export const colors = {
navy: '#1A3A4A',
sage: '#8CA5A5',
},
// Flow - Captain's Journal
flow: {
background: '#E8F6F8',
@@ -38,7 +38,7 @@ export const colors = {
archivedText: '#7A9A9A',
highlight: '#B8E0E5',
},
// Vault - Ship's Vault
vault: {
background: '#1B4D5C',
@@ -54,7 +54,7 @@ export const colors = {
warning: '#E57373',
success: '#6BBF8A',
},
// Sentinel - Lighthouse Watch
sentinel: {
background: '#1A3A4A',
@@ -70,7 +70,7 @@ export const colors = {
statusWarning: '#E5B873',
statusCritical: '#E57373',
},
// Heritage - Legacy Fleet
heritage: {
background: '#E8F6F8',
@@ -86,7 +86,7 @@ export const colors = {
confirmed: '#6BBF8A',
pending: '#E5B873',
},
// Me - Captain's Quarters
me: {
background: '#E8F6F8',

View File

@@ -28,6 +28,8 @@ export interface VaultAsset {
createdAt: Date;
updatedAt: Date;
isEncrypted: boolean;
heirEmail?: string;
rawData?: any; // For debug logging
}
// Sentinel Types
@@ -102,3 +104,13 @@ export interface LoginResponse {
token_type: string;
user: User;
}
// AI Types
export interface AIRole {
id: string;
name: string;
description: string;
systemPrompt: string;
icon: string;
iconFamily: string;
}

View 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
View 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 "【解密失败】:密钥错误或数据被篡改";
}
}
}

View 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
View File

@@ -0,0 +1,6 @@
/**
* Utility functions for Sentinel
*/
export * from './sss';
export * from './vaultAssets';

268
src/utils/sss.ts Normal file
View 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
View 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
View 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
View 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;
}