Compare commits
27 Commits
b8c241c1a0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465881c0e4 | ||
|
|
8994a3e045 | ||
|
|
e33ea62e35 | ||
|
|
96d95a50fc | ||
|
|
c1ce804d14 | ||
|
|
0aab9a838b | ||
|
|
6822638d47 | ||
|
|
5c1172a912 | ||
|
|
b5373c2d9a | ||
|
|
3ffcc60ee8 | ||
|
|
50e78c84c9 | ||
|
|
8e6c621f7b | ||
|
|
7b8511f080 | ||
|
|
f6fa19d0b2 | ||
|
|
536513ab3f | ||
|
|
240a7eea8b | ||
| d64a6557a8 | |||
|
|
22dc3abf65 | ||
|
|
ed1f6fc49d | ||
|
|
218b2e8b29 | ||
| 56bb72aab8 | |||
|
|
fb1377eb4b | ||
|
|
c07f1f20d5 | ||
| 749ed2f05a | |||
|
|
da4a7de0ad | ||
|
|
146320052e | ||
|
|
4d94888bb8 |
71
App.tsx
@@ -1,17 +1,66 @@
|
||||
/**
|
||||
* App Entry Point
|
||||
*
|
||||
* Main application component with authentication routing.
|
||||
* Shows loading screen while restoring auth state.
|
||||
*/
|
||||
import './src/polyfills';
|
||||
|
||||
import React from 'react';
|
||||
import { Buffer } from 'buffer';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { StyleSheet, View, ActivityIndicator, Text } from 'react-native';
|
||||
import TabNavigator from './src/navigation/TabNavigator';
|
||||
import AuthNavigator from './src/navigation/AuthNavigator';
|
||||
import { AuthProvider, useAuth } from './src/context/AuthContext';
|
||||
import { colors } from './src/theme/colors';
|
||||
|
||||
if (typeof globalThis !== 'undefined' && !globalThis.Buffer) {
|
||||
globalThis.Buffer = Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loading screen shown while restoring auth state
|
||||
*/
|
||||
function LoadingScreen() {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.nautical.teal} />
|
||||
<Text style={styles.loadingText}>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Main app content with auth-based routing
|
||||
*/
|
||||
function AppContent() {
|
||||
const { user, isInitializing } = useAuth();
|
||||
|
||||
// Show loading screen while restoring auth state
|
||||
if (isInitializing) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationContainer>
|
||||
<StatusBar style="auto" />
|
||||
{user ? <TabNavigator /> : <AuthNavigator />}
|
||||
</NavigationContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root App component
|
||||
*/
|
||||
export default function App() {
|
||||
return (
|
||||
<GestureHandlerRootView style={styles.container}>
|
||||
<NavigationContainer>
|
||||
<StatusBar style="auto" />
|
||||
<TabNavigator />
|
||||
</NavigationContainer>
|
||||
<AuthProvider>
|
||||
<AppContent />
|
||||
</AuthProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
@@ -19,5 +68,17 @@ export default function App() {
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.flow.backgroundGradientStart,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
color: colors.flow.textSecondary,
|
||||
},
|
||||
});
|
||||
|
||||
713
README.md
@@ -1,202 +1,595 @@
|
||||
# Sentinel App
|
||||
# Sentinel Frontend
|
||||
|
||||
[中文版](#中文版)
|
||||
A React Native mobile application for secure digital legacy management, built with Expo and TypeScript. Sentinel enables users to preserve, encrypt, and conditionally transfer their digital assets to designated heirs through a sophisticated cryptographic architecture.
|
||||
|
||||
## Digital Legacy Management
|
||||
## Table of Contents
|
||||
|
||||
Sentinel is a mobile application that helps users securely manage their digital legacy. Built with React Native (Expo) and TypeScript.
|
||||
- [Overview](#overview)
|
||||
- [Business Features](#business-features)
|
||||
- [Technical Architecture](#technical-architecture)
|
||||
- [Security & Cryptography](#security--cryptography)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Configuration](#configuration)
|
||||
- [Development](#development)
|
||||
|
||||
## Features
|
||||
## Overview
|
||||
|
||||
Sentinel is a comprehensive digital inheritance platform that addresses the critical challenge of preserving and transferring digital assets securely. The application combines advanced cryptographic techniques with an intuitive user interface, ensuring that sensitive information remains protected while enabling controlled access for designated beneficiaries.
|
||||
|
||||
### Core Value Proposition
|
||||
|
||||
- **Zero-Knowledge Architecture**: The platform cannot access user data without cryptographic keys
|
||||
- **Conditional Release**: Assets are released only when specific conditions are met (e.g., subscription expiration, inactivity)
|
||||
- **Multi-Layer Encryption**: Combines symmetric and asymmetric encryption for maximum security
|
||||
- **Distributed Trust**: Uses secret sharing to prevent single points of failure
|
||||
|
||||
## Business Features
|
||||
|
||||
### 🗞️ Flow - Captain's Journal
|
||||
- Record daily thoughts, emotions, and reflections
|
||||
|
||||
An AI-powered journaling interface for daily thoughts, emotions, and reflections.
|
||||
|
||||
**Capabilities:**
|
||||
- Multi-modal entry support: text, voice, and image inputs
|
||||
- AI-inferred emotional state tracking
|
||||
- Archive entries to the encrypted Vault
|
||||
- Support for text, voice, and image entries
|
||||
- Conversational AI with multiple role configurations:
|
||||
- Reflective Assistant: Deep introspection and emotional exploration
|
||||
- Creative Spark: Brainstorming and creative writing
|
||||
- Action Planner: Goal setting and productivity coaching
|
||||
- Empathetic Guide: Emotional support and non-judgmental listening
|
||||
- Archive entries to encrypted Vault for long-term preservation
|
||||
- Conversation summarization for efficient review
|
||||
|
||||
**Use Cases:**
|
||||
- Daily emotional tracking and mental health monitoring
|
||||
- Creative writing and idea generation
|
||||
- Goal planning and accountability
|
||||
- Therapeutic journaling with AI support
|
||||
|
||||
### 📦 Vault - The Deep Vault
|
||||
- End-to-end encrypted asset storage
|
||||
- Support for game accounts, private keys, documents, photos, wills
|
||||
- Biometric authentication required for access
|
||||
- Zero-knowledge architecture
|
||||
|
||||
End-to-end encrypted storage for sensitive digital assets.
|
||||
|
||||
**Supported Asset Types:**
|
||||
- **Game Accounts**: Credentials for gaming platforms (Steam, etc.)
|
||||
- **Private Keys**: Cryptographic keys and wallet seeds
|
||||
- **Documents**: Legal documents, contracts, and important files
|
||||
- **Photos**: Personal memories and sensitive images
|
||||
- **Wills**: Testamentary documents and final wishes
|
||||
- **Custom**: User-defined asset categories
|
||||
|
||||
**Security Features:**
|
||||
- Biometric authentication required for access (Face ID / Touch ID / Fingerprint)
|
||||
- Shamir's Secret Sharing (3-of-2 threshold) for key management
|
||||
- AES-256-GCM encryption for content protection
|
||||
- User-isolated storage (multi-account support)
|
||||
- Zero-knowledge architecture (server cannot decrypt without user shares)
|
||||
|
||||
**Workflow:**
|
||||
1. User creates asset with plaintext content
|
||||
2. System generates mnemonic phrase and splits into 3 shares
|
||||
3. Content encrypted with derived AES key
|
||||
4. One share stored on device, one on server, one for heir
|
||||
5. Any 2 shares required to recover decryption key
|
||||
|
||||
### ⚓ Sentinel - Lighthouse Watch
|
||||
- Dead Man's Switch monitoring system
|
||||
- Heartbeat confirmation mechanism
|
||||
- Subscription and activity tracking
|
||||
- Configurable grace periods
|
||||
|
||||
Dead Man's Switch monitoring system for conditional asset release.
|
||||
|
||||
**Mechanism:**
|
||||
- Continuous heartbeat monitoring of user activity
|
||||
- Subscription status tracking
|
||||
- Configurable grace periods before triggering release
|
||||
- Activity logging and audit trail
|
||||
- Status indicators: Normal, Warning, Releasing
|
||||
|
||||
**Trigger Conditions:**
|
||||
- Subscription expiration without renewal
|
||||
- Extended inactivity period
|
||||
- Manual activation by user
|
||||
- Administrative declaration (for testing/emergencies)
|
||||
|
||||
**Release Process:**
|
||||
1. System detects trigger condition
|
||||
2. Outer encryption layer removed (RSA gateway unlocked)
|
||||
3. Heirs notified of available assets
|
||||
4. Heirs can claim assets with their share + server share
|
||||
|
||||
### 🧭 Heritage - Fleet Legacy
|
||||
- Heir management with release levels
|
||||
- Customizable release order and timing
|
||||
- Payment strategy configuration
|
||||
- Legal document-style interface
|
||||
|
||||
Heir management and asset distribution system.
|
||||
|
||||
**Features:**
|
||||
- **Heir Management**: Add, edit, and remove designated beneficiaries
|
||||
- **Release Levels**: Configure priority tiers (1-3) for asset access
|
||||
- **Release Order**: Define sequence for multi-heir scenarios
|
||||
- **Payment Strategies**:
|
||||
- Prepaid: Heir pays upfront for access
|
||||
- Pay on Access: Payment required when claiming assets
|
||||
- **Legal Document Interface**: Formal, testamentary-style presentation
|
||||
- **Assignment Workflow**: Assign specific assets to specific heirs
|
||||
|
||||
**Heir Status:**
|
||||
- **Invited**: Heir has been designated but not yet confirmed
|
||||
- **Confirmed**: Heir has accepted invitation and verified identity
|
||||
|
||||
### ⛵ Me - Captain's Quarters
|
||||
- Subscription and protocol status
|
||||
- Sentinel configuration
|
||||
- Security center
|
||||
- Data export and backup
|
||||
- Social responsibility program
|
||||
|
||||
## Tech Stack
|
||||
User account management and system configuration.
|
||||
|
||||
- **Framework**: React Native (Expo SDK 52)
|
||||
- **Language**: TypeScript
|
||||
- **Navigation**: React Navigation (Bottom Tabs)
|
||||
- **Icons**: @expo/vector-icons (Feather, Ionicons, MaterialCommunityIcons, FontAwesome5)
|
||||
- **Styling**: Custom nautical theme with gradients
|
||||
**Sections:**
|
||||
- **Subscription Status**: Current tier, expiration date, features enabled
|
||||
- **Protocol Information**: Version tracking and update status
|
||||
- **Sentinel Configuration**: Heartbeat intervals, grace periods, monitoring settings
|
||||
- **Security Center**:
|
||||
- Biometric settings
|
||||
- Vault state management
|
||||
- Key recovery options
|
||||
- **Data Export**: Backup encrypted vault data
|
||||
- **Social Responsibility**: Program information and participation
|
||||
|
||||
## Project Structure
|
||||
## Technical Architecture
|
||||
|
||||
### Technology Stack
|
||||
|
||||
| Category | Technology | Purpose |
|
||||
|----------|-----------|---------|
|
||||
| **Framework** | React Native (Expo SDK 52) | Cross-platform mobile development |
|
||||
| **Language** | TypeScript 5.3+ | Type-safe development |
|
||||
| **Navigation** | React Navigation 6 | Bottom tabs + stack navigation |
|
||||
| **State Management** | React Context API | Authentication and app state |
|
||||
| **Storage** | AsyncStorage | Local persistence (auth tokens, vault state) |
|
||||
| **Icons** | @expo/vector-icons | Feather, Ionicons, FontAwesome5, Material |
|
||||
| **Styling** | StyleSheet + LinearGradient | Custom nautical-themed UI |
|
||||
| **Crypto** | Web Crypto API + Polyfills | Cryptographic operations |
|
||||
| **AI Integration** | LangChain + LangGraph | AI conversation management |
|
||||
| **Build System** | Metro Bundler | JavaScript bundling |
|
||||
|
||||
### Architecture Patterns
|
||||
|
||||
#### Service Layer Architecture
|
||||
|
||||
The application uses a modular service architecture for API communication:
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/
|
||||
│ └── common/
|
||||
│ ├── BiometricModal.tsx
|
||||
│ ├── Icons.tsx
|
||||
│ └── VaultDoorAnimation.tsx
|
||||
├── navigation/
|
||||
│ └── TabNavigator.tsx
|
||||
├── screens/
|
||||
│ ├── FlowScreen.tsx
|
||||
│ ├── VaultScreen.tsx
|
||||
│ ├── SentinelScreen.tsx
|
||||
│ ├── HeritageScreen.tsx
|
||||
│ └── MeScreen.tsx
|
||||
├── theme/
|
||||
│ └── colors.ts
|
||||
└── types/
|
||||
└── index.ts
|
||||
|
||||
assets/
|
||||
├── icon.png # App icon (1024x1024)
|
||||
├── adaptive-icon.png # Android adaptive icon
|
||||
├── splash.png # Splash screen
|
||||
├── favicon.png # Web favicon (32x32)
|
||||
├── favicon.svg # SVG favicon for web
|
||||
├── logo.svg # Vector logo (512x512)
|
||||
└── images/
|
||||
└── captain-avatar.svg # Avatar placeholder
|
||||
src/services/
|
||||
├── auth.service.ts # Authentication (login, register)
|
||||
├── assets.service.ts # Asset CRUD and inheritance
|
||||
├── vault.service.ts # Vault encryption/decryption
|
||||
├── ai.service.ts # AI conversation proxy
|
||||
├── admin.service.ts # Administrative operations
|
||||
├── langgraph.service.ts # LangGraph workflow integration
|
||||
└── storage.service.ts # AsyncStorage abstraction
|
||||
```
|
||||
|
||||
## Icons & Branding
|
||||
**Service Pattern:**
|
||||
- Centralized API configuration
|
||||
- Mock mode support for development
|
||||
- Consistent error handling
|
||||
- Debug logging integration
|
||||
- Type-safe request/response interfaces
|
||||
|
||||
The Sentinel brand uses a nautical anchor-and-star logo on a teal (#459E9E) background.
|
||||
#### Context-Based State Management
|
||||
|
||||
### Logo Elements
|
||||
- **Anchor**: Symbolizes stability and anchoring your digital legacy
|
||||
- **Star/Compass**: Represents guidance and direction for heirs
|
||||
- **Teal Color**: Evokes ocean depth and calm security
|
||||
**AuthContext** (`src/context/AuthContext.tsx`):
|
||||
- Manages authentication state
|
||||
- Handles token persistence
|
||||
- Provides user information throughout app
|
||||
- Handles initialization and loading states
|
||||
|
||||
### Generating Icons
|
||||
|
||||
```bash
|
||||
# View icon specifications
|
||||
node scripts/generate-icons.js
|
||||
**Usage Pattern:**
|
||||
```typescript
|
||||
const { user, token, login, logout, isInitializing } = useAuth();
|
||||
```
|
||||
|
||||
Use the `assets/logo.svg` as the source and export to required sizes.
|
||||
#### Navigation Structure
|
||||
|
||||
```
|
||||
App (Root)
|
||||
├── AuthNavigator (if not authenticated)
|
||||
│ ├── LoginScreen
|
||||
│ └── RegisterScreen
|
||||
└── TabNavigator (if authenticated)
|
||||
├── FlowScreen (🗞️)
|
||||
├── VaultScreen (📦)
|
||||
├── SentinelScreen (⚓)
|
||||
├── HeritageScreen (🧭)
|
||||
└── MeScreen (⛵)
|
||||
```
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
**Core:**
|
||||
- `react`: 18.3.1
|
||||
- `react-native`: 0.76.9
|
||||
- `expo`: ~52.0.0
|
||||
|
||||
**Navigation:**
|
||||
- `@react-navigation/native`: ^6.1.18
|
||||
- `@react-navigation/bottom-tabs`: ^6.6.1
|
||||
- `@react-navigation/native-stack`: ^6.11.0
|
||||
|
||||
**Cryptography:**
|
||||
- `@noble/ciphers`: ^1.3.0 (AES encryption)
|
||||
- `@noble/hashes`: ^1.8.0 (Hash functions)
|
||||
- `bip39`: ^3.1.0 (Mnemonic generation)
|
||||
- `expo-crypto`: ~14.0.2 (Crypto polyfills)
|
||||
|
||||
**AI & Language Models:**
|
||||
- `@langchain/core`: ^1.1.18
|
||||
- `@langchain/langgraph`: ^1.1.3
|
||||
|
||||
**Storage & Utilities:**
|
||||
- `@react-native-async-storage/async-storage`: ^2.2.0
|
||||
- `buffer`: ^6.0.3 (Node.js Buffer polyfill)
|
||||
- `readable-stream`: ^4.7.0
|
||||
|
||||
## Security & Cryptography
|
||||
|
||||
### Encryption Architecture
|
||||
|
||||
Sentinel implements a multi-layer encryption system:
|
||||
|
||||
#### Layer 1: Vault Encryption (User-Controlled)
|
||||
|
||||
**Process:**
|
||||
1. Generate 12-word BIP-39 mnemonic phrase
|
||||
2. Derive AES-256 key using PBKDF2:
|
||||
- Input: Mnemonic phrase
|
||||
- Salt: `Sentinel_Salt_2026`
|
||||
- Iterations: 100,000
|
||||
- Hash: SHA-256
|
||||
- Output: 32-byte AES key
|
||||
3. Encrypt plaintext with AES-256-GCM:
|
||||
- Mode: Galois/Counter Mode (authenticated encryption)
|
||||
- IV: 16-byte random nonce
|
||||
- Tag: 16-byte authentication tag
|
||||
- Output: `IV + Ciphertext + Tag` (hex encoded)
|
||||
|
||||
**Implementation:** `src/utils/vaultCrypto.ts`
|
||||
|
||||
#### Layer 2: Secret Sharing (Distributed Trust)
|
||||
|
||||
**Shamir's Secret Sharing (3-of-2 Threshold):**
|
||||
|
||||
1. Convert mnemonic to entropy (BigInt representation)
|
||||
2. Split entropy into 3 shares using linear polynomial:
|
||||
```
|
||||
f(x) = secret + a*x (mod P)
|
||||
```
|
||||
Where:
|
||||
- `secret`: Mnemonic entropy
|
||||
- `a`: Random coefficient
|
||||
- `P`: Prime modulus (2^127 - 1)
|
||||
- Shares at x = 1, 2, 3
|
||||
|
||||
3. Share Distribution:
|
||||
- **Device Share (S0)**: Stored locally on user's device
|
||||
- **Cloud Share (S1)**: Stored on Sentinel server
|
||||
- **Heir Share (S2)**: Provided to designated heir
|
||||
|
||||
4. Recovery: Any 2 shares can recover original entropy via Lagrange interpolation
|
||||
|
||||
**Implementation:** `src/utils/sss.ts`
|
||||
|
||||
#### Layer 3: Gateway Encryption (Server-Controlled)
|
||||
|
||||
**RSA Outer Encryption:**
|
||||
- Server generates RSA-4096 key pair per asset
|
||||
- Inner encrypted content encrypted again with RSA public key
|
||||
- Private key held by server, released only on trigger conditions
|
||||
- Prevents unauthorized access even if inner encryption is compromised
|
||||
|
||||
**Note:** Gateway encryption is handled by backend; frontend sends `content_inner_encrypted` which backend wraps with RSA.
|
||||
|
||||
### Key Management
|
||||
|
||||
**Storage Strategy:**
|
||||
- User-isolated keys: `getVaultStorageKeys(userId)` generates per-user storage keys
|
||||
- Device share (S0): Stored in AsyncStorage with user-scoped key
|
||||
- Mnemonic backup: Optional local backup of mnemonic portion (encrypted)
|
||||
- Multi-account support: Each user has independent vault state
|
||||
|
||||
**Storage Keys:**
|
||||
```typescript
|
||||
{
|
||||
INITIALIZED: `sentinel_vault_initialized_u{userId}`,
|
||||
SHARE_DEVICE: `sentinel_vault_s0_u{userId}`,
|
||||
MNEMONIC_PART_LOCAL: `sentinel_mnemonic_part_local_u{userId}`,
|
||||
AES_KEY: `sentinel_aes_key_u{userId}`,
|
||||
SHARE_SERVER: `sentinel_share_server_u{userId}`,
|
||||
SHARE_HEIR: `sentinel_share_heir_u{userId}`
|
||||
}
|
||||
```
|
||||
|
||||
### Security Properties
|
||||
|
||||
1. **Zero-Knowledge**: Server cannot decrypt user data without user's share
|
||||
2. **Forward Secrecy**: Compromising one share reveals nothing about the secret
|
||||
3. **Authenticated Encryption**: GCM mode ensures data integrity
|
||||
4. **Key Derivation**: PBKDF2 with high iteration count prevents brute force
|
||||
5. **Distributed Trust**: No single point of failure for key recovery
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ and npm
|
||||
- Expo CLI (installed globally or via npx)
|
||||
- iOS Simulator (macOS) or Android Emulator / physical device
|
||||
- Backend API server running (or use `NO_BACKEND_MODE`)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start the development server
|
||||
# Start Expo development server
|
||||
npx expo start
|
||||
```
|
||||
|
||||
### Platform-Specific Commands
|
||||
|
||||
```bash
|
||||
# iOS Simulator (macOS only)
|
||||
npm run ios
|
||||
|
||||
# Android Emulator / Device
|
||||
npm run android
|
||||
|
||||
# Web Browser
|
||||
npm run web
|
||||
```
|
||||
|
||||
### Development Modes
|
||||
|
||||
**With Backend:**
|
||||
1. Ensure backend server is running (default: `http://localhost:8000`)
|
||||
2. Update `API_BASE_URL` in `src/config/index.ts` if needed
|
||||
3. Start Expo: `npx expo start`
|
||||
|
||||
**Without Backend (Mock Mode):**
|
||||
1. Set `NO_BACKEND_MODE = true` in `src/config/index.ts`
|
||||
2. All API calls return mock data
|
||||
3. Useful for UI development and testing
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── App.tsx # Root component with auth routing
|
||||
├── app.json # Expo configuration
|
||||
├── package.json # Dependencies and scripts
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
│
|
||||
├── assets/ # Static assets
|
||||
│ ├── icon.png # App icon (1024x1024)
|
||||
│ ├── adaptive-icon.png # Android adaptive icon
|
||||
│ ├── splash.png # Splash screen
|
||||
│ ├── favicon.png # Web favicon
|
||||
│ ├── logo.svg # Vector logo
|
||||
│ └── images/ # Additional images
|
||||
│
|
||||
├── scripts/ # Build scripts
|
||||
│ └── generate-icons.js # Icon generation utility
|
||||
│
|
||||
└── src/
|
||||
├── components/ # Reusable UI components
|
||||
│ └── common/
|
||||
│ ├── BiometricModal.tsx # Biometric auth modal
|
||||
│ ├── Icons.tsx # Icon helper component
|
||||
│ └── VaultDoorAnimation.tsx # Vault unlock animation
|
||||
│
|
||||
├── config/ # Configuration
|
||||
│ └── index.ts # Centralized config (API, endpoints, etc.)
|
||||
│
|
||||
├── context/ # React Context providers
|
||||
│ └── AuthContext.tsx # Authentication state management
|
||||
│
|
||||
├── hooks/ # Custom React hooks
|
||||
│ ├── index.ts
|
||||
│ └── useVaultAssets.ts # Vault asset management hook
|
||||
│
|
||||
├── navigation/ # Navigation configuration
|
||||
│ ├── AuthNavigator.tsx # Login/Register navigation
|
||||
│ └── TabNavigator.tsx # Main app tab navigation
|
||||
│
|
||||
├── screens/ # Screen components
|
||||
│ ├── FlowScreen.tsx # AI journaling interface
|
||||
│ ├── VaultScreen.tsx # Encrypted asset management
|
||||
│ ├── SentinelScreen.tsx # Dead Man's Switch monitoring
|
||||
│ ├── HeritageScreen.tsx # Heir management
|
||||
│ ├── MeScreen.tsx # User settings and account
|
||||
│ ├── LoginScreen.tsx # Authentication
|
||||
│ └── RegisterScreen.tsx
|
||||
│
|
||||
├── services/ # API service layer
|
||||
│ ├── index.ts # Service exports
|
||||
│ ├── auth.service.ts # Authentication API
|
||||
│ ├── assets.service.ts # Asset CRUD API
|
||||
│ ├── vault.service.ts # Vault encryption utilities
|
||||
│ ├── ai.service.ts # AI conversation API
|
||||
│ ├── admin.service.ts # Admin operations
|
||||
│ ├── langgraph.service.ts # LangGraph integration
|
||||
│ └── storage.service.ts # AsyncStorage abstraction
|
||||
│
|
||||
├── theme/ # Design system
|
||||
│ └── colors.ts # Color palette and typography
|
||||
│
|
||||
├── types/ # TypeScript type definitions
|
||||
│ └── index.ts # Shared types and interfaces
|
||||
│
|
||||
├── utils/ # Utility functions
|
||||
│ ├── index.ts
|
||||
│ ├── crypto_core.ts # Crypto utilities (if needed)
|
||||
│ ├── crypto_polyfill.ts # Crypto API polyfills
|
||||
│ ├── sss.ts # Shamir's Secret Sharing
|
||||
│ ├── vaultCrypto.ts # AES encryption/decryption
|
||||
│ ├── vaultAssets.ts # Asset management utilities
|
||||
│ ├── token_utils.ts # Token management
|
||||
│ └── async_hooks_mock.ts # Async hooks polyfill
|
||||
│
|
||||
└── polyfills.ts # Global polyfills (Buffer, etc.)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
All configuration is centralized in `src/config/index.ts`:
|
||||
|
||||
```typescript
|
||||
// Development mode
|
||||
export const NO_BACKEND_MODE = false; // Use mock data
|
||||
export const DEBUG_MODE = true; // API debug logging
|
||||
|
||||
// API Configuration
|
||||
export const API_BASE_URL = 'http://localhost:8000';
|
||||
export const API_TIMEOUT = 30000; // 30 seconds
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
Defined in `API_ENDPOINTS` constant:
|
||||
|
||||
| Category | Endpoint | Method | Purpose |
|
||||
|----------|----------|--------|---------|
|
||||
| **Auth** | `/login` | POST | User authentication |
|
||||
| | `/register` | POST | User registration |
|
||||
| **Assets** | `/assets/get` | GET | Fetch user's assets |
|
||||
| | `/assets/create` | POST | Create new asset |
|
||||
| | `/assets/claim` | POST | Claim inherited asset |
|
||||
| | `/assets/assign` | POST | Assign asset to heir |
|
||||
| | `/assets/delete` | POST | Delete asset |
|
||||
| **AI** | `/ai/proxy` | POST | AI conversation proxy |
|
||||
| | `/get_ai_roles` | GET | Fetch available AI roles |
|
||||
| **Admin** | `/admin/declare-guale` | POST | Admin: Declare user deceased |
|
||||
|
||||
### Vault Storage Configuration
|
||||
|
||||
Vault storage uses user-scoped keys for multi-account support:
|
||||
|
||||
```typescript
|
||||
// Get storage keys for a user
|
||||
const keys = getVaultStorageKeys(userId);
|
||||
|
||||
// Keys are prefixed with user ID to prevent cross-user access
|
||||
// Format: sentinel_vault_{key}_{suffix}
|
||||
// Suffix: _u{userId} for authenticated users, _guest for guests
|
||||
```
|
||||
|
||||
### AI Configuration
|
||||
|
||||
AI roles and system prompts are configurable:
|
||||
|
||||
```typescript
|
||||
export const AI_CONFIG = {
|
||||
DEFAULT_SYSTEM_PROMPT: '...',
|
||||
MOCK_RESPONSE_DELAY: 500,
|
||||
ROLES: [
|
||||
{ id: 'reflective', name: 'Reflective Assistant', ... },
|
||||
{ id: 'creative', name: 'Creative Spark', ... },
|
||||
{ id: 'planner', name: 'Action Planner', ... },
|
||||
{ id: 'empathetic', name: 'Empathetic Guide', ... },
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Code Style
|
||||
|
||||
- **TypeScript**: Strict mode enabled, all files typed
|
||||
- **Components**: Functional components with hooks
|
||||
- **Naming**: PascalCase for components, camelCase for functions/variables
|
||||
- **Imports**: Absolute imports preferred (configured in tsconfig.json)
|
||||
|
||||
### Testing
|
||||
|
||||
**Manual Testing:**
|
||||
- Use Expo Go app on physical device for real-world testing
|
||||
- Test biometric authentication on actual devices
|
||||
- Verify encryption/decryption flows with real backend
|
||||
|
||||
**Mock Mode Testing:**
|
||||
- Enable `NO_BACKEND_MODE` for UI/UX testing
|
||||
- Mock responses simulate real API behavior
|
||||
- Useful for rapid iteration without backend dependency
|
||||
|
||||
### Debugging
|
||||
|
||||
**API Debug Logging:**
|
||||
- Enabled when `DEBUG_MODE = true`
|
||||
- Logs all API requests/responses to console
|
||||
- Includes request URLs, headers, and response data
|
||||
|
||||
**React Native Debugger:**
|
||||
- Use React Native Debugger or Chrome DevTools
|
||||
- Inspect component state and props
|
||||
- Monitor network requests
|
||||
|
||||
### Building for Production
|
||||
|
||||
```bash
|
||||
# Build for iOS
|
||||
eas build --platform ios
|
||||
|
||||
# Build for Android
|
||||
eas build --platform android
|
||||
|
||||
# Build for Web
|
||||
npx expo export:web
|
||||
```
|
||||
|
||||
**Note:** Requires Expo Application Services (EAS) account for native builds.
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Crypto API Not Available:**
|
||||
- Ensure polyfills are loaded (`src/polyfills.ts`)
|
||||
- Check that `crypto.subtle` is available in environment
|
||||
- React Native requires polyfills for Web Crypto API
|
||||
|
||||
**AsyncStorage Errors:**
|
||||
- Ensure `@react-native-async-storage/async-storage` is properly linked
|
||||
- Check storage permissions on device
|
||||
- Clear storage if corrupted: `AsyncStorage.clear()`
|
||||
|
||||
**Navigation Issues:**
|
||||
- Ensure `NavigationContainer` wraps navigators
|
||||
- Check that screens are properly registered
|
||||
- Verify tab bar configuration matches screen names
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
- **Nautical Theme**: Captain's sanctum aesthetic with anchors, ship wheels, and ocean colors
|
||||
- **Emotional Balance**: Warm and secure feeling across different tabs
|
||||
- **Privacy First**: Zero-knowledge architecture, local encryption
|
||||
- **Elegant UI**: Mint gradients, serif typography, subtle shadows
|
||||
### Nautical Theme
|
||||
|
||||
The application uses a consistent nautical/maritime aesthetic:
|
||||
|
||||
- **Color Palette**: Teal (#459E9E) primary, ocean blues, mint gradients
|
||||
- **Iconography**: Anchors, ship wheels, compasses, lighthouses
|
||||
- **Terminology**: Captain, Fleet, Vault, Sentinel, Heritage
|
||||
- **Typography**: Serif fonts for formal sections, sans-serif for UI
|
||||
|
||||
### User Experience Principles
|
||||
|
||||
1. **Privacy First**: Encryption happens locally, user controls keys
|
||||
2. **Transparency**: Clear explanation of security mechanisms
|
||||
3. **Accessibility**: Biometric auth for convenience, fallback options available
|
||||
4. **Elegance**: Clean, modern UI with subtle animations
|
||||
5. **Trust**: Visual indicators for security status and system health
|
||||
|
||||
## License
|
||||
|
||||
Private - All rights reserved.
|
||||
|
||||
## Support
|
||||
|
||||
For issues, questions, or contributions, please contact the development team.
|
||||
|
||||
---
|
||||
|
||||
# 中文版
|
||||
|
||||
[English Version](#sentinel-app)
|
||||
|
||||
## 数字遗产管理
|
||||
|
||||
Sentinel 是一款帮助用户安全管理数字遗产的移动应用程序。使用 React Native (Expo) 和 TypeScript 构建。
|
||||
|
||||
## 功能特性
|
||||
|
||||
### 🗞️ Flow - 船长日志
|
||||
- 记录日常想法、情感和反思
|
||||
- AI 推断情感状态追踪
|
||||
- 将条目归档到加密保险库
|
||||
- 支持文本、语音和图像条目
|
||||
|
||||
### 📦 Vault - 深海宝库
|
||||
- 端到端加密资产存储
|
||||
- 支持游戏账号、私钥、文档、照片、遗嘱
|
||||
- 需要生物识别认证才能访问
|
||||
- 零知识架构
|
||||
|
||||
### ⚓ Sentinel - 灯塔守望
|
||||
- 死人开关监控系统
|
||||
- 心跳确认机制
|
||||
- 订阅和活动追踪
|
||||
- 可配置的冷静期
|
||||
|
||||
### 🧭 Heritage - 舰队遗产
|
||||
- 继承人管理与释放等级
|
||||
- 可自定义释放顺序和时间
|
||||
- 付款策略配置
|
||||
- 法律文书风格界面
|
||||
|
||||
### ⛵ Me - 船长室
|
||||
- 订阅和协议状态
|
||||
- 哨兵配置
|
||||
- 安全中心
|
||||
- 数据导出和备份
|
||||
- 社会责任计划
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: React Native (Expo SDK 52)
|
||||
- **语言**: TypeScript
|
||||
- **导航**: React Navigation (底部标签)
|
||||
- **图标**: @expo/vector-icons
|
||||
- **样式**: 自定义航海主题配渐变
|
||||
|
||||
## 运行项目
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 启动开发服务器
|
||||
npx expo start
|
||||
```
|
||||
|
||||
## 图标与品牌
|
||||
|
||||
Sentinel 品牌使用青色(#459E9E)背景上的航海锚与星星标志。
|
||||
|
||||
### 标志元素
|
||||
- **锚**: 象征稳定性和锚定你的数字遗产
|
||||
- **星星/指南针**: 代表对继承人的指引和方向
|
||||
- **青色**: 唤起海洋深度和平静的安全感
|
||||
|
||||
### 生成图标
|
||||
|
||||
```bash
|
||||
# 查看图标规格
|
||||
node scripts/generate-icons.js
|
||||
```
|
||||
|
||||
使用 `assets/logo.svg` 作为源文件并导出所需尺寸。
|
||||
|
||||
## 设计理念
|
||||
|
||||
- **航海主题**: 船长圣殿美学,配以锚、船舵和海洋色彩
|
||||
- **情感平衡**: 不同标签页带来温暖而安全的感觉
|
||||
- **隐私优先**: 零知识架构,本地加密
|
||||
- **优雅界面**: 薄荷渐变、衬线字体、柔和阴影
|
||||
**Version**: 2.0.0
|
||||
**Last Updated**: February 2026
|
||||
|
||||
6
app.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 86 B After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 105 B After Width: | Height: | Size: 70 B |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 52 B After Width: | Height: | Size: 70 B |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 70 B |
15
metro.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
config.resolver.extraNodeModules = {
|
||||
...config.resolver.extraNodeModules,
|
||||
crypto: path.resolve(__dirname, 'src/utils/crypto_polyfill.ts'),
|
||||
stream: require.resolve('readable-stream'),
|
||||
vm: require.resolve('vm-browserify'),
|
||||
async_hooks: path.resolve(__dirname, 'src/utils/async_hooks_mock.ts'),
|
||||
'node:async_hooks': path.resolve(__dirname, 'src/utils/async_hooks_mock.ts'),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
703
package-lock.json
generated
15
package.json
@@ -11,13 +11,23 @@
|
||||
"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",
|
||||
"@react-navigation/native-stack": "^6.11.0",
|
||||
"bip39": "^3.1.0",
|
||||
"buffer": "^6.0.3",
|
||||
"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",
|
||||
"expo-linear-gradient": "~14.0.2",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"react": "18.3.1",
|
||||
@@ -27,7 +37,10 @@
|
||||
"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-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",
|
||||
|
||||
@@ -62,20 +62,18 @@ export default function BiometricModal({
|
||||
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();
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
229
src/config/index.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* Application Configuration
|
||||
*
|
||||
* This file contains all configuration settings for the frontend application.
|
||||
* Centralized configuration makes it easier to manage environment-specific settings.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// Environment Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Set to true to use mock data instead of real backend calls.
|
||||
* Useful for development and testing without a running backend.
|
||||
*/
|
||||
export const NO_BACKEND_MODE = false;
|
||||
|
||||
/**
|
||||
* Enable debug logging for API calls
|
||||
*/
|
||||
export const DEBUG_MODE = true;
|
||||
|
||||
// =============================================================================
|
||||
// API Configuration
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Base URL for the backend API server
|
||||
*/
|
||||
export const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
/**
|
||||
* API request timeout in milliseconds
|
||||
*/
|
||||
export const API_TIMEOUT = 30000;
|
||||
|
||||
/**
|
||||
* API Endpoints
|
||||
* All backend API routes are defined here for easy reference and maintenance.
|
||||
*/
|
||||
export const API_ENDPOINTS = {
|
||||
// Authentication
|
||||
AUTH: {
|
||||
LOGIN: '/login',
|
||||
REGISTER: '/register',
|
||||
},
|
||||
|
||||
// Assets Management
|
||||
ASSETS: {
|
||||
GET: '/assets/get',
|
||||
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
|
||||
ADMIN: {
|
||||
DECLARE_GUALE: '/admin/declare-guale',
|
||||
},
|
||||
} 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
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Build full API URL from endpoint
|
||||
* @param endpoint - API endpoint path (e.g., '/login')
|
||||
* @returns Full URL (e.g., 'http://192.168.56.103:8000/login')
|
||||
*/
|
||||
export function buildApiUrl(endpoint: string): string {
|
||||
return `${API_BASE_URL}${endpoint}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default headers for API requests
|
||||
* @param token - Optional JWT token for authenticated requests
|
||||
* @returns Headers object
|
||||
*/
|
||||
export function getApiHeaders(token?: string): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log API debug information
|
||||
* Only logs when DEBUG_MODE is enabled
|
||||
*/
|
||||
export function logApiDebug(label: string, data: unknown): void {
|
||||
if (DEBUG_MODE) {
|
||||
console.log(`[API Debug] ${label}:`, data);
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mock User Configuration (for NO_BACKEND_MODE)
|
||||
// =============================================================================
|
||||
|
||||
export const MOCK_CONFIG = {
|
||||
USER: {
|
||||
id: 999,
|
||||
username: 'MockCaptain',
|
||||
email: 'captain@sentinel.local',
|
||||
public_key: 'mock_public_key',
|
||||
is_admin: true,
|
||||
guale: false,
|
||||
tier: 'premium',
|
||||
tier_expires_at: '2026-12-31T23:59:59Z',
|
||||
last_active_at: new Date().toISOString(),
|
||||
},
|
||||
ACCESS_TOKEN: 'mock_access_token',
|
||||
RESPONSE_DELAY: 200, // milliseconds
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// AI Service Configuration
|
||||
// =============================================================================
|
||||
|
||||
export const AI_CONFIG = {
|
||||
/**
|
||||
* Default system prompt for AI conversations
|
||||
*/
|
||||
DEFAULT_SYSTEM_PROMPT: 'You are a helpful journal assistant. Help the user reflect on their thoughts and feelings.',
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// =============================================================================
|
||||
// Export Type Definitions
|
||||
// =============================================================================
|
||||
|
||||
export type ApiEndpoint = typeof API_ENDPOINTS;
|
||||
213
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* AuthContext - Authentication State Management
|
||||
*
|
||||
* Provides authentication state and methods throughout the app.
|
||||
* Uses AsyncStorage for persistent login state.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
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
|
||||
// =============================================================================
|
||||
|
||||
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
|
||||
const STORAGE_KEYS = {
|
||||
TOKEN: '@auth_token',
|
||||
USER: '@auth_user',
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// Context
|
||||
// =============================================================================
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
// =============================================================================
|
||||
// Provider Component
|
||||
// =============================================================================
|
||||
|
||||
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);
|
||||
|
||||
// Load saved auth state on app start
|
||||
useEffect(() => {
|
||||
loadStoredAuth();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Load stored authentication from AsyncStorage
|
||||
*/
|
||||
const loadStoredAuth = async () => {
|
||||
try {
|
||||
const [storedToken, storedUser] = await Promise.all([
|
||||
AsyncStorage.getItem(STORAGE_KEYS.TOKEN),
|
||||
AsyncStorage.getItem(STORAGE_KEYS.USER),
|
||||
]);
|
||||
|
||||
if (storedToken && storedUser) {
|
||||
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);
|
||||
} finally {
|
||||
setIsInitializing(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const saveAuth = async (authToken: string, authUser: User) => {
|
||||
try {
|
||||
await Promise.all([
|
||||
AsyncStorage.setItem(STORAGE_KEYS.TOKEN, authToken),
|
||||
AsyncStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(authUser)),
|
||||
]);
|
||||
console.log('[Auth] Session saved for user:', authUser.username);
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to save auth:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear authentication from AsyncStorage
|
||||
*/
|
||||
const clearAuth = async () => {
|
||||
try {
|
||||
await Promise.all([
|
||||
AsyncStorage.removeItem(STORAGE_KEYS.TOKEN),
|
||||
AsyncStorage.removeItem(STORAGE_KEYS.USER),
|
||||
]);
|
||||
console.log('[Auth] Session cleared');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failed to clear auth:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign in with username and password
|
||||
*/
|
||||
const signIn = async (credentials: LoginRequest) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await authService.login(credentials);
|
||||
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 {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sign up and automatically sign in
|
||||
*/
|
||||
const signUp = async (data: RegisterRequest) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await authService.register(data);
|
||||
// After successful registration, sign in automatically
|
||||
await signIn({ username: data.username, password: data.password });
|
||||
} catch (error) {
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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,
|
||||
aiRoles,
|
||||
isLoading,
|
||||
isInitializing,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
refreshAIRoles
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Hook
|
||||
// =============================================================================
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
6
src/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* React hooks for Sentinel
|
||||
*/
|
||||
|
||||
export { useVaultAssets } from './useVaultAssets';
|
||||
export type { CreateAssetResult, UseVaultAssetsReturn } from './useVaultAssets';
|
||||
278
src/hooks/useVaultAssets.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
/**
|
||||
* useVaultAssets: Encapsulates /assets/get and /assets/create for VaultScreen.
|
||||
* - Fetches assets when vault is unlocked and token exists.
|
||||
* - Exposes createAsset with 401/network error handling and list refresh on success.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as bip39 from 'bip39';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { assetsService } from '../services/assets.service';
|
||||
import { getVaultStorageKeys, DEBUG_MODE } from '../config';
|
||||
import { SentinelVault } from '../utils/crypto_core';
|
||||
import { storageService } from '../services/storage.service';
|
||||
import {
|
||||
initialVaultAssets,
|
||||
mapApiAssetsToVaultAssets,
|
||||
type ApiAsset,
|
||||
} from '../utils/vaultAssets';
|
||||
import type { VaultAsset } from '../types';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface CreateAssetResult {
|
||||
success: boolean;
|
||||
isUnauthorized?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UseVaultAssetsReturn {
|
||||
/** Current list (mock until API succeeds) */
|
||||
assets: VaultAsset[];
|
||||
/** Replace list (e.g. after external refresh) */
|
||||
setAssets: React.Dispatch<React.SetStateAction<VaultAsset[]>>;
|
||||
/** Refetch from GET /assets/get */
|
||||
refreshAssets: () => Promise<void>;
|
||||
/** Create asset via POST /assets/create; on success refreshes list */
|
||||
createAsset: (params: { title: string; content: string }) => Promise<CreateAssetResult>;
|
||||
/** Delete asset via POST /assets/delete; on success refreshes list */
|
||||
deleteAsset: (assetId: number) => Promise<CreateAssetResult>;
|
||||
/** Assign asset to heir via POST /assets/assign */
|
||||
assignAsset: (assetId: number, heirEmail: string) => Promise<CreateAssetResult>;
|
||||
/** True while create request is in flight */
|
||||
isSealing: boolean;
|
||||
/** Error message from last create failure (non-401) */
|
||||
createError: string | null;
|
||||
/** Clear createError */
|
||||
clearCreateError: () => void;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Hook
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Vault assets list + create. Fetches on unlock when token exists; keeps mock on error.
|
||||
*/
|
||||
export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
|
||||
const { user, token, signOut } = useAuth();
|
||||
const [assets, setAssets] = useState<VaultAsset[]>(initialVaultAssets);
|
||||
const [isSealing, setIsSealing] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const refreshAssets = useCallback(async () => {
|
||||
if (!token) return;
|
||||
try {
|
||||
const list = await assetsService.getMyAssets(token);
|
||||
if (Array.isArray(list)) {
|
||||
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
|
||||
if (/Could not validate credentials/i.test(rawMessage)) {
|
||||
signOut();
|
||||
}
|
||||
// Keep current assets (mock or previous fetch)
|
||||
}
|
||||
}, [token, signOut]);
|
||||
|
||||
// Fetch list when unlocked and token exists
|
||||
useEffect(() => {
|
||||
if (!isUnlocked || !token) return;
|
||||
let cancelled = false;
|
||||
assetsService
|
||||
.getMyAssets(token)
|
||||
.then((list) => {
|
||||
if (!cancelled && Array.isArray(list)) {
|
||||
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
|
||||
if (/Could not validate credentials/i.test(rawMessage)) {
|
||||
signOut();
|
||||
}
|
||||
}
|
||||
// Keep initial (mock) assets
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isUnlocked, token]);
|
||||
|
||||
const createAsset = useCallback(
|
||||
async ({
|
||||
title,
|
||||
content,
|
||||
}: {
|
||||
title: string;
|
||||
content: string;
|
||||
}): Promise<CreateAssetResult> => {
|
||||
if (!token) {
|
||||
return { success: false, error: 'Not logged in.' };
|
||||
}
|
||||
setIsSealing(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const vaultKeys = getVaultStorageKeys(user?.id ?? null);
|
||||
const [s1Str, aesKeyHex, s0Str, s2Str] = await Promise.all([
|
||||
AsyncStorage.getItem(vaultKeys.SHARE_SERVER),
|
||||
AsyncStorage.getItem(vaultKeys.AES_KEY),
|
||||
AsyncStorage.getItem(vaultKeys.SHARE_DEVICE),
|
||||
AsyncStorage.getItem(vaultKeys.SHARE_HEIR),
|
||||
]);
|
||||
|
||||
if (!s1Str || !aesKeyHex) {
|
||||
throw new Error('Vault keys missing. Please re-unlock your vault.');
|
||||
}
|
||||
|
||||
const vault = new SentinelVault();
|
||||
const aesKey = Buffer.from(aesKeyHex, 'hex');
|
||||
const encryptedBuffer = vault.encryptData(aesKey, content.trim());
|
||||
const content_inner_encrypted = encryptedBuffer.toString('hex');
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
console.log('[DEBUG] Crypto Data during Asset Creation:');
|
||||
console.log(' s0 (Device):', s0Str);
|
||||
console.log(' s1 (Server):', s1Str);
|
||||
console.log(' s2 (Heir): ', s2Str);
|
||||
console.log(' AES Key: ', aesKeyHex);
|
||||
console.log(' Encrypted: ', content_inner_encrypted);
|
||||
}
|
||||
|
||||
const createdAsset = await assetsService.createAsset(
|
||||
{
|
||||
title: title.trim(),
|
||||
private_key_shard: s1Str,
|
||||
content_inner_encrypted,
|
||||
},
|
||||
token
|
||||
);
|
||||
|
||||
// Backup plaintext content locally
|
||||
if (createdAsset && createdAsset.id && user?.id) {
|
||||
await storageService.saveAssetBackup(createdAsset.id, content, user.id);
|
||||
}
|
||||
await refreshAssets();
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
const status =
|
||||
err && typeof err === 'object' && 'status' in err
|
||||
? (err as { status?: number }).status
|
||||
: undefined;
|
||||
const rawMessage =
|
||||
err instanceof Error ? err.message : String(err ?? 'Failed to create.');
|
||||
const isUnauthorized =
|
||||
status === 401 || /401|Unauthorized/i.test(rawMessage);
|
||||
|
||||
if (isUnauthorized) {
|
||||
signOut();
|
||||
return { success: false, isUnauthorized: true };
|
||||
}
|
||||
|
||||
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
|
||||
? 'Network error. Please check that the backend is running and reachable (see API_BASE_URL in config).'
|
||||
: rawMessage;
|
||||
setCreateError(friendlyMessage);
|
||||
return { success: false, error: friendlyMessage };
|
||||
} finally {
|
||||
setIsSealing(false);
|
||||
}
|
||||
},
|
||||
[token, user, refreshAssets, signOut]
|
||||
);
|
||||
|
||||
const deleteAsset = useCallback(
|
||||
async (assetId: number): Promise<CreateAssetResult> => {
|
||||
if (!token) {
|
||||
return { success: false, error: 'Not logged in.' };
|
||||
}
|
||||
setIsSealing(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await assetsService.deleteAsset(assetId, token);
|
||||
await refreshAssets();
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
const status =
|
||||
err && typeof err === 'object' && 'status' in err
|
||||
? (err as { status?: number }).status
|
||||
: undefined;
|
||||
const rawMessage =
|
||||
err instanceof Error ? err.message : String(err ?? 'Failed to delete.');
|
||||
const isUnauthorized =
|
||||
status === 401 || /401|Unauthorized/i.test(rawMessage);
|
||||
|
||||
if (isUnauthorized) {
|
||||
signOut();
|
||||
return { success: false, isUnauthorized: true };
|
||||
}
|
||||
|
||||
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
|
||||
? 'Network error. Please check that the backend is running and reachable.'
|
||||
: rawMessage;
|
||||
setCreateError(friendlyMessage);
|
||||
return { success: false, error: friendlyMessage };
|
||||
} finally {
|
||||
setIsSealing(false);
|
||||
}
|
||||
},
|
||||
[token, refreshAssets, signOut]
|
||||
);
|
||||
|
||||
const assignAsset = useCallback(
|
||||
async (assetId: number, heirEmail: string): Promise<CreateAssetResult> => {
|
||||
if (!token) {
|
||||
return { success: false, error: 'Not logged in.' };
|
||||
}
|
||||
setIsSealing(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
await assetsService.assignAsset({ asset_id: assetId, heir_email: heirEmail }, token);
|
||||
await refreshAssets();
|
||||
return { success: true };
|
||||
} catch (err: unknown) {
|
||||
const status =
|
||||
err && typeof err === 'object' && 'status' in err
|
||||
? (err as { status?: number }).status
|
||||
: undefined;
|
||||
const rawMessage =
|
||||
err instanceof Error ? err.message : String(err ?? 'Failed to assign.');
|
||||
const isUnauthorized =
|
||||
status === 401 || /401|Unauthorized/i.test(rawMessage);
|
||||
|
||||
if (isUnauthorized) {
|
||||
signOut();
|
||||
return { success: false, isUnauthorized: true };
|
||||
}
|
||||
|
||||
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
|
||||
? 'Network error. Please check that the backend is running and reachable.'
|
||||
: rawMessage;
|
||||
setCreateError(friendlyMessage);
|
||||
return { success: false, error: friendlyMessage };
|
||||
} finally {
|
||||
setIsSealing(false);
|
||||
}
|
||||
},
|
||||
[token, signOut]
|
||||
);
|
||||
|
||||
const clearCreateError = useCallback(() => setCreateError(null), []);
|
||||
|
||||
return {
|
||||
assets,
|
||||
setAssets,
|
||||
refreshAssets,
|
||||
createAsset,
|
||||
deleteAsset,
|
||||
assignAsset,
|
||||
isSealing,
|
||||
createError,
|
||||
clearCreateError,
|
||||
};
|
||||
}
|
||||
20
src/navigation/AuthNavigator.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import LoginScreen from '../screens/LoginScreen';
|
||||
import RegisterScreen from '../screens/RegisterScreen';
|
||||
|
||||
const Stack = createNativeStackNavigator();
|
||||
|
||||
export default function AuthNavigator() {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
animation: 'slide_from_right',
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { colors, borderRadius, typography } from '../theme/colors';
|
||||
// Screens
|
||||
import FlowScreen from '../screens/FlowScreen';
|
||||
import SentinelScreen from '../screens/SentinelScreen';
|
||||
import HeritageScreen from '../screens/HeritageScreen';
|
||||
// import HeritageScreen from '../screens/HeritageScreen'; // Heritage functionality moved to Me and Sentinel
|
||||
import MeScreen from '../screens/MeScreen';
|
||||
|
||||
const Tab = createBottomTabNavigator();
|
||||
@@ -104,6 +104,7 @@ export default function TabNavigator() {
|
||||
tabBarStyle: styles.tabBarDark,
|
||||
}}
|
||||
/>
|
||||
{/* Heritage tab commented out - functionality moved to Me (Fleet Legacy) and Sentinel (Shadow Vault)
|
||||
<Tab.Screen
|
||||
name="Heritage"
|
||||
component={HeritageScreen}
|
||||
@@ -118,6 +119,7 @@ export default function TabNavigator() {
|
||||
),
|
||||
}}
|
||||
/>
|
||||
*/}
|
||||
<Tab.Screen
|
||||
name="Me"
|
||||
component={MeScreen}
|
||||
|
||||
45
src/polyfills.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Polyfills that must run before any other app code (including LangChain/LangGraph).
|
||||
* This file is imported as the very first line in App.tsx so that ReadableStream
|
||||
* and crypto.getRandomValues exist before @langchain/core / uuid are loaded.
|
||||
*/
|
||||
import 'web-streams-polyfill';
|
||||
|
||||
// Ensure globalThis has ReadableStream (main polyfill may not patch in RN/Metro)
|
||||
const g = typeof globalThis !== 'undefined' ? globalThis : (typeof global !== 'undefined' ? global : (typeof self !== 'undefined' ? self : {}));
|
||||
if (typeof (g as any).ReadableStream === 'undefined') {
|
||||
const ponyfill = require('web-streams-polyfill/dist/ponyfill.js');
|
||||
(g as any).ReadableStream = ponyfill.ReadableStream;
|
||||
(g as any).WritableStream = ponyfill.WritableStream;
|
||||
(g as any).TransformStream = ponyfill.TransformStream;
|
||||
}
|
||||
|
||||
// Polyfill crypto.getRandomValues for React Native/Expo (required by uuid, LangChain, etc.)
|
||||
if (typeof g !== 'undefined') {
|
||||
const cryptoObj = (g as any).crypto;
|
||||
if (!cryptoObj || typeof (cryptoObj.getRandomValues) !== 'function') {
|
||||
try {
|
||||
const ExpoCrypto = require('expo-crypto');
|
||||
const getRandomValues = (array: ArrayBufferView): ArrayBufferView => {
|
||||
ExpoCrypto.getRandomValues(array);
|
||||
return array;
|
||||
};
|
||||
if (!(g as any).crypto) (g as any).crypto = {};
|
||||
(g as any).crypto.getRandomValues = getRandomValues;
|
||||
} catch (e) {
|
||||
console.warn('[polyfills] crypto.getRandomValues polyfill failed:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Polyfill AbortSignal.prototype.throwIfAborted (required by fetch/LangChain in RN; not present in older runtimes)
|
||||
const AbortSignalGlobal = (g as any).AbortSignal;
|
||||
if (typeof AbortSignalGlobal === 'function' && AbortSignalGlobal.prototype && typeof AbortSignalGlobal.prototype.throwIfAborted !== 'function') {
|
||||
AbortSignalGlobal.prototype.throwIfAborted = function (this: AbortSignal) {
|
||||
if (this.aborted) {
|
||||
const e = new Error('Aborted');
|
||||
e.name = 'AbortError';
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
256
src/screens/LoginScreen.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
Alert,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, borderRadius, typography, shadows } from '../theme/colors';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export default function LoginScreen({ navigation }: any) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { signIn, isLoading } = useAuth();
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError(null);
|
||||
if (!email || !password) {
|
||||
setError('Please enter both username and password.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await signIn({ username: email, password });
|
||||
} catch (err: any) {
|
||||
setError('Login failed. Please check your credentials.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={[colors.sentinel.backgroundGradientStart, colors.sentinel.backgroundGradientEnd]}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.content}
|
||||
>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoContainer}>
|
||||
<MaterialCommunityIcons name="anchor" size={48} color={colors.sentinel.primary} />
|
||||
</View>
|
||||
<Text style={styles.title}>Welcome Aboard!</Text>
|
||||
<Text style={styles.subtitle}>Sign in to access your sanctuaryy</Text>
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons name="alert-circle-outline" size={20} color={colors.sentinel.statusCritical} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Feather name="mail" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Username"
|
||||
placeholderTextColor={colors.sentinel.textSecondary}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Feather name="lock" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
placeholderTextColor={colors.sentinel.textSecondary}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.forgotButton}>
|
||||
<Text style={styles.forgotText}>Forgot Password?</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.loginButton}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={colors.white} />
|
||||
) : (
|
||||
<Text style={styles.loginButtonText}>Login</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.dividerLine} />
|
||||
<Text style={styles.dividerText}>OR</Text>
|
||||
<View style={styles.dividerLine} />
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.registerLink}
|
||||
onPress={() => navigation.navigate('Register')}
|
||||
>
|
||||
<Text style={styles.registerText}>
|
||||
Don't have an account? <Text style={styles.registerHighlight}>Join the Fleet</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
paddingHorizontal: spacing.lg,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xxl,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(184, 224, 229, 0.2)',
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.fontSize.xxl,
|
||||
fontWeight: 'bold',
|
||||
color: colors.sentinel.text,
|
||||
marginBottom: spacing.xs,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: typography.fontSize.base,
|
||||
color: colors.sentinel.textSecondary,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 68, 68, 0.1)',
|
||||
padding: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
marginTop: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 68, 68, 0.2)',
|
||||
},
|
||||
errorText: {
|
||||
color: colors.sentinel.statusCritical,
|
||||
fontSize: typography.fontSize.sm,
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(184, 224, 229, 0.1)',
|
||||
marginBottom: spacing.base,
|
||||
height: 56,
|
||||
paddingHorizontal: spacing.base,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: colors.sentinel.text,
|
||||
fontSize: typography.fontSize.base,
|
||||
},
|
||||
forgotButton: {
|
||||
alignSelf: 'flex-end',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
forgotText: {
|
||||
color: colors.sentinel.primary,
|
||||
fontSize: typography.fontSize.sm,
|
||||
},
|
||||
loginButton: {
|
||||
backgroundColor: colors.nautical.teal,
|
||||
height: 56,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...shadows.glow,
|
||||
},
|
||||
loginButtonText: {
|
||||
color: colors.white,
|
||||
fontSize: typography.fontSize.md,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: spacing.xl,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(184, 224, 229, 0.1)',
|
||||
},
|
||||
dividerText: {
|
||||
color: colors.sentinel.textSecondary,
|
||||
paddingHorizontal: spacing.md,
|
||||
fontSize: typography.fontSize.sm,
|
||||
},
|
||||
registerLink: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
registerText: {
|
||||
color: colors.sentinel.textSecondary,
|
||||
fontSize: typography.fontSize.base,
|
||||
},
|
||||
registerHighlight: {
|
||||
color: colors.sentinel.primary,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
});
|
||||
290
src/screens/RegisterScreen.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
SafeAreaView,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Feather, MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, borderRadius, typography, shadows } from '../theme/colors';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { ActivityIndicator } from 'react-native';
|
||||
|
||||
export default function RegisterScreen({ navigation }: any) {
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { signUp, isLoading } = useAuth();
|
||||
|
||||
const handleRegister = async () => {
|
||||
setError(null);
|
||||
if (!name || !email || !password || !confirmPassword) {
|
||||
setError('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
setError('Please enter a valid email address.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await signUp({ username: name, email, password });
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Registration failed. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={[colors.sentinel.backgroundGradientStart, colors.sentinel.backgroundGradientEnd]}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.content}
|
||||
>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Feather name="arrow-left" size={24} color={colors.sentinel.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.header}>
|
||||
<View style={styles.logoContainer}>
|
||||
<MaterialCommunityIcons name="compass-outline" size={48} color={colors.sentinel.primary} />
|
||||
</View>
|
||||
<Text style={styles.title}>Join the Fleet</Text>
|
||||
<Text style={styles.subtitle}>Begin your journey with Sentinel</Text>
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons name="alert-circle-outline" size={20} color={colors.sentinel.statusCritical} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputContainer}>
|
||||
<Feather name="user" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Captain's Name"
|
||||
placeholderTextColor={colors.sentinel.textSecondary}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Feather name="mail" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Email"
|
||||
placeholderTextColor={colors.sentinel.textSecondary}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Feather name="lock" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Password"
|
||||
placeholderTextColor={colors.sentinel.textSecondary}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<Feather name="check-circle" size={20} color={colors.sentinel.textSecondary} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="Confirm Password"
|
||||
placeholderTextColor={colors.sentinel.textSecondary}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.registerButton, isLoading && styles.disabledButton]}
|
||||
activeOpacity={0.8}
|
||||
onPress={handleRegister}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator color={colors.white} />
|
||||
) : (
|
||||
<Text style={styles.registerButtonText}>Create Account</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.loginLink}
|
||||
onPress={() => navigation.navigate('Login')}
|
||||
>
|
||||
<Text style={styles.loginLinkText}>
|
||||
Already have an account? <Text style={styles.highlight}>Login</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
</LinearGradient>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
backButton: {
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.lg,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
header: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
logoContainer: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(184, 224, 229, 0.2)',
|
||||
},
|
||||
title: {
|
||||
fontSize: typography.fontSize.xxl,
|
||||
fontWeight: 'bold',
|
||||
color: colors.sentinel.text,
|
||||
marginBottom: spacing.xs,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: typography.fontSize.base,
|
||||
color: colors.sentinel.textSecondary,
|
||||
},
|
||||
form: {
|
||||
width: '100%',
|
||||
},
|
||||
inputContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(184, 224, 229, 0.1)',
|
||||
marginBottom: spacing.base,
|
||||
height: 56,
|
||||
paddingHorizontal: spacing.base,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: colors.sentinel.text,
|
||||
fontSize: typography.fontSize.base,
|
||||
},
|
||||
registerButton: {
|
||||
backgroundColor: colors.nautical.seafoam,
|
||||
height: 56,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: spacing.md,
|
||||
...shadows.glow,
|
||||
},
|
||||
registerButtonText: {
|
||||
color: colors.white,
|
||||
fontSize: typography.fontSize.md,
|
||||
fontWeight: 'bold',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
loginLink: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xl,
|
||||
},
|
||||
loginLinkText: {
|
||||
color: colors.sentinel.textSecondary,
|
||||
fontSize: typography.fontSize.base,
|
||||
},
|
||||
highlight: {
|
||||
color: colors.nautical.seafoam,
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
disabledButton: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
errorContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(255, 68, 68, 0.1)',
|
||||
padding: spacing.sm,
|
||||
borderRadius: borderRadius.md,
|
||||
marginTop: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 68, 68, 0.2)',
|
||||
},
|
||||
errorText: {
|
||||
color: colors.sentinel.statusCritical,
|
||||
fontSize: typography.fontSize.sm,
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
@@ -7,17 +7,30 @@ import {
|
||||
TouchableOpacity,
|
||||
SafeAreaView,
|
||||
Animated,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
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 { 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;
|
||||
icon: StatusIconName;
|
||||
description: string;
|
||||
gradientColors: [string, string];
|
||||
}> = {
|
||||
@@ -46,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'));
|
||||
@@ -76,102 +75,101 @@ export default function SentinelScreen() {
|
||||
const [pulseAnim] = useState(new Animated.Value(1));
|
||||
const [glowAnim] = useState(new Animated.Value(0.5));
|
||||
const [rotateAnim] = useState(new Animated.Value(0));
|
||||
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();
|
||||
|
||||
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]);
|
||||
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
||||
|
||||
// Reset status if warning
|
||||
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`;
|
||||
};
|
||||
|
||||
@@ -211,7 +209,7 @@ export default function SentinelScreen() {
|
||||
transform: [{ scale: pulseAnim }],
|
||||
opacity: glowAnim,
|
||||
backgroundColor: `${currentStatus.color}20`,
|
||||
}
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||
@@ -219,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 }]}>
|
||||
@@ -249,25 +247,39 @@ 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 style={styles.metricValue}>{formatTimeAgo(lastFlowActivity)}</Text>
|
||||
<Text style={styles.metricTime}>{formatDateTime(lastFlowActivity)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Shadow Vault Access */}
|
||||
<View style={styles.vaultAccessCard}>
|
||||
<View style={styles.vaultAccessIcon}>
|
||||
<MaterialCommunityIcons name="treasure-chest" size={22} color={colors.nautical.teal} />
|
||||
</View>
|
||||
<View style={styles.vaultAccessContent}>
|
||||
<Text style={styles.vaultAccessTitle}>Shadow Vault</Text>
|
||||
<Text style={styles.vaultAccessText}>
|
||||
Access sealed assets from the Lighthouse.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.vaultAccessButton}
|
||||
onPress={openVault}
|
||||
activeOpacity={0.8}
|
||||
accessibilityLabel="Open Shadow Vault"
|
||||
accessibilityRole="button"
|
||||
>
|
||||
<Text style={styles.vaultAccessButtonText}>Open</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Heartbeat Button */}
|
||||
@@ -275,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]}
|
||||
@@ -303,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>
|
||||
))}
|
||||
@@ -313,30 +325,40 @@ export default function SentinelScreen() {
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
</LinearGradient>
|
||||
|
||||
{/* Vault Modal */}
|
||||
<Modal
|
||||
visible={showVault}
|
||||
animationType="slide"
|
||||
onRequestClose={() => setShowVault(false)}
|
||||
>
|
||||
<View style={styles.vaultModalContainer}>
|
||||
{showVault ? <VaultScreen /> : null}
|
||||
<TouchableOpacity
|
||||
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',
|
||||
@@ -442,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',
|
||||
@@ -498,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,
|
||||
@@ -513,4 +531,60 @@ const styles = StyleSheet.create({
|
||||
color: colors.sentinel.textSecondary,
|
||||
fontFamily: typography.fontFamily.mono,
|
||||
},
|
||||
vaultAccessCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.sentinel.cardBackground,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.base,
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.sentinel.cardBorder,
|
||||
},
|
||||
vaultAccessIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: `${colors.nautical.teal}20`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
vaultAccessContent: { flex: 1 },
|
||||
vaultAccessTitle: {
|
||||
fontSize: typography.fontSize.base,
|
||||
fontWeight: '600',
|
||||
color: colors.sentinel.text,
|
||||
marginBottom: 2,
|
||||
},
|
||||
vaultAccessText: {
|
||||
fontSize: typography.fontSize.sm,
|
||||
color: colors.sentinel.textSecondary,
|
||||
},
|
||||
vaultAccessButton: {
|
||||
backgroundColor: colors.nautical.teal,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
vaultAccessButtonText: {
|
||||
color: colors.nautical.cream,
|
||||
fontWeight: '700',
|
||||
fontSize: typography.fontSize.sm,
|
||||
},
|
||||
vaultModalContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.vault.background,
|
||||
},
|
||||
vaultCloseButton: {
|
||||
position: 'absolute',
|
||||
top: spacing.xl + spacing.lg,
|
||||
right: spacing.lg,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: 'rgba(26, 58, 74, 0.65)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
79
src/services/admin.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Admin Service
|
||||
*
|
||||
* Handles admin-only API operations.
|
||||
*/
|
||||
|
||||
import {
|
||||
NO_BACKEND_MODE,
|
||||
API_ENDPOINTS,
|
||||
MOCK_CONFIG,
|
||||
buildApiUrl,
|
||||
getApiHeaders,
|
||||
logApiDebug,
|
||||
} from '../config';
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions
|
||||
// =============================================================================
|
||||
|
||||
export interface DeclareGualeRequest {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface DeclareGualeResponse {
|
||||
message: string;
|
||||
username: string;
|
||||
guale: boolean;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Admin Service
|
||||
// =============================================================================
|
||||
|
||||
export const adminService = {
|
||||
/**
|
||||
* Declare a user as deceased (guale)
|
||||
* Admin only operation
|
||||
* @param request - Username to declare as deceased
|
||||
* @param token - JWT token for authentication (must be admin)
|
||||
* @returns Success response
|
||||
*/
|
||||
async declareGuale(request: DeclareGualeRequest, token: string): Promise<DeclareGualeResponse> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('Declare Guale', 'Using mock mode');
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
message: `User ${request.username} has been declared as deceased`,
|
||||
username: request.username,
|
||||
guale: true,
|
||||
});
|
||||
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.ADMIN.DECLARE_GUALE);
|
||||
logApiDebug('Declare Guale URL', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(token),
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
|
||||
logApiDebug('Declare Guale Response Status', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Failed to declare user as deceased');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Declare guale error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
331
src/services/ai.service.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* AI Service
|
||||
*
|
||||
* Handles communication with the AI proxy endpoint for chat completions.
|
||||
*/
|
||||
|
||||
import {
|
||||
NO_BACKEND_MODE,
|
||||
API_ENDPOINTS,
|
||||
AI_CONFIG,
|
||||
buildApiUrl,
|
||||
getApiHeaders,
|
||||
logApiDebug,
|
||||
} from '../config';
|
||||
import { AIRole } from '../types';
|
||||
import { trimInternalMessages } from '../utils/token_utils';
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions
|
||||
// =============================================================================
|
||||
|
||||
export interface AIMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface AIRequest {
|
||||
messages: AIMessage[];
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export interface AIResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: AIMessage;
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mock Response Generator
|
||||
// =============================================================================
|
||||
|
||||
const createMockResponse = (userMessage: string): AIResponse => {
|
||||
return {
|
||||
id: `mock-${Date.now()}`,
|
||||
object: 'chat.completion',
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: 'mock-model',
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: `I received your message: "${userMessage}". This is a mock response since the backend is not connected.`,
|
||||
},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 30,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// AI Service
|
||||
// =============================================================================
|
||||
|
||||
export const aiService = {
|
||||
/**
|
||||
* Send chat messages to the AI proxy
|
||||
* @param messages - Array of chat messages
|
||||
* @param token - JWT token for authentication
|
||||
* @returns AI response
|
||||
*/
|
||||
async chat(messages: AIMessage[], token?: string): Promise<AIResponse> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('AI Chat', 'Using mock mode');
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
const lastUserMessage = messages.filter((m) => m.role === 'user').pop();
|
||||
resolve(createMockResponse(lastUserMessage?.content || 'Hello'));
|
||||
}, AI_CONFIG.MOCK_RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
|
||||
|
||||
logApiDebug('AI Request', {
|
||||
url,
|
||||
hasToken: !!token,
|
||||
messageCount: messages.length,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(token),
|
||||
body: JSON.stringify({ messages } as AIRequest),
|
||||
});
|
||||
|
||||
logApiDebug('AI Response Status', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logApiDebug('AI Error Response', errorText);
|
||||
|
||||
let errorDetail = 'AI request failed';
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
errorDetail = errorData.detail || errorDetail;
|
||||
} catch {
|
||||
errorDetail = errorText || errorDetail;
|
||||
}
|
||||
throw new Error(`${response.status}: ${errorDetail}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
logApiDebug('AI Success', {
|
||||
id: data.id,
|
||||
model: data.model,
|
||||
choicesCount: data.choices?.length,
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('AI proxy error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 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, systemPrompt?: string): Promise<string> {
|
||||
const messages: AIMessage[] = [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPrompt || AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content,
|
||||
},
|
||||
];
|
||||
|
||||
const response = await this.chat(messages, token);
|
||||
return response.choices[0]?.message?.content || 'No response';
|
||||
},
|
||||
|
||||
/**
|
||||
* Send a message with an image to AI for analysis
|
||||
* @param content - User message content
|
||||
* @param imageBase64 - Base64 encoded image data
|
||||
* @param token - JWT token for authentication
|
||||
* @returns AI response text
|
||||
*/
|
||||
async sendMessageWithImage(content: string, imageBase64: string, token?: string): Promise<string> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('AI Image Analysis', 'Using mock mode');
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve('This is a mock image analysis response. The image appears to show an interesting scene. In production, this would be analyzed by Gemini AI.');
|
||||
}, AI_CONFIG.MOCK_RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
|
||||
|
||||
logApiDebug('AI Image Request', {
|
||||
url,
|
||||
hasToken: !!token,
|
||||
hasImage: !!imageBase64,
|
||||
});
|
||||
|
||||
// Gemini vision format - using multimodal content
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: content,
|
||||
},
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: {
|
||||
url: `data:image/jpeg;base64,${imageBase64}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(token),
|
||||
body: JSON.stringify({ messages }),
|
||||
});
|
||||
|
||||
logApiDebug('AI Image Response Status', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logApiDebug('AI Image Error Response', errorText);
|
||||
|
||||
let errorDetail: string = 'AI image request failed';
|
||||
try {
|
||||
const errorData = JSON.parse(errorText);
|
||||
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;
|
||||
}
|
||||
throw new Error(`${response.status}: ${errorDetail}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
logApiDebug('AI Image Success', {
|
||||
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];
|
||||
}
|
||||
},
|
||||
};
|
||||
290
src/services/assets.service.ts
Normal file
@@ -0,0 +1,290 @@
|
||||
/**
|
||||
* Assets Service
|
||||
*
|
||||
* Handles all asset-related API operations including CRUD and inheritance.
|
||||
*/
|
||||
|
||||
import {
|
||||
NO_BACKEND_MODE,
|
||||
API_ENDPOINTS,
|
||||
MOCK_CONFIG,
|
||||
buildApiUrl,
|
||||
getApiHeaders,
|
||||
logApiDebug,
|
||||
} from '../config';
|
||||
|
||||
// =============================================================================
|
||||
// Type Definitions
|
||||
// =============================================================================
|
||||
|
||||
export interface Asset {
|
||||
id: number;
|
||||
title: string;
|
||||
author_id: number;
|
||||
private_key_shard: string;
|
||||
content_outer_encrypted: string;
|
||||
heir_email?: string;
|
||||
}
|
||||
|
||||
export interface AssetCreate {
|
||||
title: string;
|
||||
private_key_shard: string;
|
||||
content_inner_encrypted: string;
|
||||
}
|
||||
|
||||
export interface AssetClaim {
|
||||
asset_id: number;
|
||||
private_key_shard: string;
|
||||
}
|
||||
|
||||
export interface AssetClaimResponse {
|
||||
asset_id: number;
|
||||
title: string;
|
||||
decrypted_content: string;
|
||||
server_shard_key: string;
|
||||
}
|
||||
|
||||
export interface AssetAssign {
|
||||
asset_id: number;
|
||||
heir_email: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Mock Data
|
||||
// =============================================================================
|
||||
|
||||
const MOCK_ASSETS: Asset[] = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Mock Asset 1',
|
||||
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,
|
||||
title: 'Mock Asset 2',
|
||||
author_id: MOCK_CONFIG.USER.id,
|
||||
private_key_shard: 'mock_shard_2',
|
||||
content_outer_encrypted: 'mock_encrypted_content_2',
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// Assets Service
|
||||
// =============================================================================
|
||||
|
||||
export const assetsService = {
|
||||
/**
|
||||
* Get all assets for the current user
|
||||
* @param token - JWT token for authentication
|
||||
* @returns Array of user's assets
|
||||
*/
|
||||
async getMyAssets(token: string): Promise<Asset[]> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('Get Assets', 'Using mock mode');
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => resolve(MOCK_ASSETS), MOCK_CONFIG.RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.ASSETS.GET);
|
||||
logApiDebug('Get Assets URL', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: getApiHeaders(token),
|
||||
});
|
||||
|
||||
logApiDebug('Get Assets Response Status', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Failed to fetch assets');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Get assets error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new asset
|
||||
* @param asset - Asset creation data
|
||||
* @param token - JWT token for authentication
|
||||
* @returns Created asset
|
||||
*/
|
||||
async createAsset(asset: AssetCreate, token: string): Promise<Asset> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('Create Asset', 'Using mock mode');
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
id: Date.now(),
|
||||
title: asset.title,
|
||||
author_id: MOCK_CONFIG.USER.id,
|
||||
private_key_shard: asset.private_key_shard,
|
||||
content_outer_encrypted: asset.content_inner_encrypted,
|
||||
});
|
||||
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.ASSETS.CREATE);
|
||||
logApiDebug('Create Asset URL', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(token),
|
||||
body: JSON.stringify(asset),
|
||||
});
|
||||
|
||||
const responseStatus = response.status;
|
||||
logApiDebug('Create Asset Response Status', responseStatus);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error('Create asset error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Claim an inherited asset
|
||||
* @param claim - Asset claim data
|
||||
* @param token - JWT token for authentication
|
||||
* @returns Claimed asset with decrypted content
|
||||
*/
|
||||
async claimAsset(claim: AssetClaim, token: string): Promise<AssetClaimResponse> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('Claim Asset', 'Using mock mode');
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
asset_id: claim.asset_id,
|
||||
title: 'Mock Claimed Asset',
|
||||
decrypted_content: 'This is the decrypted content of the claimed asset.',
|
||||
server_shard_key: 'mock_server_shard',
|
||||
});
|
||||
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.ASSETS.CLAIM);
|
||||
logApiDebug('Claim Asset URL', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(token),
|
||||
body: JSON.stringify(claim),
|
||||
});
|
||||
|
||||
logApiDebug('Claim Asset Response Status', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Failed to claim asset');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Claim asset error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Assign an asset to an heir
|
||||
* @param assignment - Asset assignment data
|
||||
* @param token - JWT token for authentication
|
||||
* @returns Success message
|
||||
*/
|
||||
async assignAsset(assignment: AssetAssign, token: string): Promise<{ message: string }> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('Assign Asset', 'Using mock mode');
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ message: `Asset assigned to ${assignment.heir_email}` });
|
||||
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.ASSETS.ASSIGN);
|
||||
logApiDebug('Assign Asset URL', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(token),
|
||||
body: JSON.stringify(assignment),
|
||||
});
|
||||
|
||||
logApiDebug('Assign Asset Response Status', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Failed to assign asset');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Assign asset error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an asset
|
||||
* @param assetId - ID of the asset to delete
|
||||
* @param token - JWT token for authentication
|
||||
* @returns Success message
|
||||
*/
|
||||
async deleteAsset(assetId: number, token: string): Promise<{ message: string }> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('Delete Asset', `Using mock mode for ID: ${assetId}`);
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ message: 'Asset deleted successfully' });
|
||||
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.ASSETS.DELETE);
|
||||
logApiDebug('Delete Asset URL', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(token),
|
||||
body: JSON.stringify({ asset_id: assetId }),
|
||||
});
|
||||
|
||||
logApiDebug('Delete Asset Response Status', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Failed to delete asset');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Delete asset error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
87
src/services/auth.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { LoginRequest, LoginResponse, RegisterRequest, User } from '../types';
|
||||
import {
|
||||
NO_BACKEND_MODE,
|
||||
API_ENDPOINTS,
|
||||
MOCK_CONFIG,
|
||||
buildApiUrl,
|
||||
getApiHeaders,
|
||||
logApiDebug,
|
||||
} from '../config';
|
||||
|
||||
export const authService = {
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('Login', 'Using mock mode');
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
access_token: MOCK_CONFIG.ACCESS_TOKEN,
|
||||
token_type: 'bearer',
|
||||
user: { ...MOCK_CONFIG.USER, username: credentials.username } as User,
|
||||
});
|
||||
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.AUTH.LOGIN);
|
||||
logApiDebug('Login URL', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify(credentials),
|
||||
});
|
||||
|
||||
logApiDebug('Login Response Status', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || 'Login failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
logApiDebug('Login Success', { username: data.user?.username });
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async register(data: RegisterRequest): Promise<User> {
|
||||
if (NO_BACKEND_MODE) {
|
||||
logApiDebug('Register', 'Using mock mode');
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({ ...MOCK_CONFIG.USER, username: data.username } as User);
|
||||
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||
});
|
||||
}
|
||||
|
||||
const url = buildApiUrl(API_ENDPOINTS.AUTH.REGISTER);
|
||||
logApiDebug('Register URL', url);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: getApiHeaders(),
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
logApiDebug('Register Response Status', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail?.[0]?.msg || errorData.detail || 'Registration failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
logApiDebug('Register Success', { username: result.username });
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
31
src/services/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Services Index
|
||||
*
|
||||
* Central export for all API services.
|
||||
* Import services from here for cleaner imports.
|
||||
*
|
||||
* Usage:
|
||||
* import { authService, aiService, assetsService, adminService } from '../services';
|
||||
*/
|
||||
|
||||
export { authService } from './auth.service';
|
||||
export { aiService, type AIMessage, type AIRequest, type AIResponse } from './ai.service';
|
||||
export {
|
||||
assetsService,
|
||||
type Asset,
|
||||
type AssetCreate,
|
||||
type AssetClaim,
|
||||
type AssetClaimResponse,
|
||||
type AssetAssign
|
||||
} from './assets.service';
|
||||
export {
|
||||
adminService,
|
||||
type DeclareGualeRequest,
|
||||
type DeclareGualeResponse
|
||||
} from './admin.service';
|
||||
export {
|
||||
createVaultPayload,
|
||||
createAssetPayload,
|
||||
type CreateVaultPayloadResult,
|
||||
type CreateAssetPayloadResult,
|
||||
} from './vault.service';
|
||||
96
src/services/langgraph.service.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* LangGraph Service
|
||||
*
|
||||
* Implements AI chat logic using LangGraph.js for state management
|
||||
* and context handling.
|
||||
*/
|
||||
|
||||
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
|
||||
import { BaseMessage, HumanMessage, AIMessage, SystemMessage } from "@langchain/core/messages";
|
||||
import { aiService } from "./ai.service";
|
||||
import { trimLangChainMessages } from "../utils/token_utils";
|
||||
|
||||
// =============================================================================
|
||||
// Settings
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* Define the State using Annotation (Standard for latest LangGraph.js)
|
||||
*/
|
||||
const GraphAnnotation = Annotation.Root({
|
||||
messages: Annotation<BaseMessage[]>({
|
||||
reducer: (x, y) => x.concat(y),
|
||||
default: () => [],
|
||||
}),
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Graph Definition
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* The main node that calls our existing AI API
|
||||
*/
|
||||
async function callModel(state: typeof GraphAnnotation.State, config: any) {
|
||||
const { messages } = state;
|
||||
const { token } = config.configurable || {};
|
||||
|
||||
// 1. Trim messages to stay under token limit
|
||||
const trimmedMessages = trimLangChainMessages(messages);
|
||||
|
||||
// 2. Convert LangChain messages to our internal AIMessage format for the API
|
||||
const apiMessages = trimmedMessages.map(m => {
|
||||
let role: 'system' | 'user' | 'assistant' = 'user';
|
||||
const type = (m as any)._getType?.() || (m instanceof SystemMessage ? 'system' : m instanceof HumanMessage ? 'human' : m instanceof AIMessage ? 'ai' : 'user');
|
||||
|
||||
if (type === 'system') role = 'system';
|
||||
else if (type === 'human') role = 'user';
|
||||
else if (type === 'ai') role = 'assistant';
|
||||
|
||||
return {
|
||||
role,
|
||||
content: m.content.toString()
|
||||
};
|
||||
});
|
||||
|
||||
// 3. Call the proxy service
|
||||
const response = await aiService.chat(apiMessages, token);
|
||||
const content = response.choices[0]?.message?.content || "No response generated";
|
||||
|
||||
// 4. Return the new message to satisfy the Graph (it will be appended due to reducer)
|
||||
return {
|
||||
messages: [new AIMessage(content)]
|
||||
};
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Service Export
|
||||
// =============================================================================
|
||||
|
||||
export const langGraphService = {
|
||||
/**
|
||||
* Run the chat graph with history
|
||||
*/
|
||||
async execute(
|
||||
currentMessages: BaseMessage[],
|
||||
userToken: string,
|
||||
): Promise<string> {
|
||||
// Define the graph
|
||||
const workflow = new StateGraph(GraphAnnotation)
|
||||
.addNode("agent", callModel)
|
||||
.addEdge(START, "agent")
|
||||
.addEdge("agent", END);
|
||||
|
||||
const app = workflow.compile();
|
||||
|
||||
// Execute the graph
|
||||
const result = await app.invoke(
|
||||
{ messages: currentMessages },
|
||||
{ configurable: { token: userToken } }
|
||||
);
|
||||
|
||||
// Return the content of the last message (the AI response)
|
||||
const lastMsg = result.messages[result.messages.length - 1];
|
||||
return lastMsg.content.toString();
|
||||
}
|
||||
};
|
||||
147
src/services/storage.service.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Storage Service
|
||||
*
|
||||
* Handles local persistence of chat history and active conversations
|
||||
* using AsyncStorage with user-specific isolation.
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
// =============================================================================
|
||||
// Constants
|
||||
// =============================================================================
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
CHAT_HISTORY: '@sentinel:chat_history',
|
||||
CURRENT_MESSAGES: '@sentinel:current_messages',
|
||||
ASSET_BACKUP: '@sentinel:asset_backup',
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// Service Implementation
|
||||
// =============================================================================
|
||||
|
||||
export const storageService = {
|
||||
/**
|
||||
* Get user-specific storage key
|
||||
*/
|
||||
getUserKey(baseKey: string, userId: string | number): string {
|
||||
return `${baseKey}:user_${userId}`;
|
||||
},
|
||||
|
||||
/**
|
||||
* Save the complete list of chat sessions to local storage for a specific user
|
||||
*/
|
||||
async saveChatHistory(history: any[], userId: string | number): Promise<void> {
|
||||
try {
|
||||
const jsonValue = JSON.stringify(history);
|
||||
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
|
||||
await AsyncStorage.setItem(key, jsonValue);
|
||||
console.log(`[Storage] Saved chat history for user ${userId}:`, history.length, 'sessions');
|
||||
} catch (e) {
|
||||
console.error('Error saving chat history:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the list of chat sessions from local storage for a specific user
|
||||
*/
|
||||
async getChatHistory(userId: string | number): Promise<any[]> {
|
||||
try {
|
||||
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
|
||||
const jsonValue = await AsyncStorage.getItem(key);
|
||||
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
|
||||
console.log(`[Storage] Loaded chat history for user ${userId}:`, result.length, 'sessions');
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error('Error getting chat history:', e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save the current active conversation messages for a specific role and user
|
||||
*/
|
||||
async saveCurrentChat(roleId: string, messages: any[], userId: string | number): Promise<void> {
|
||||
try {
|
||||
const jsonValue = JSON.stringify(messages);
|
||||
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
|
||||
await AsyncStorage.setItem(key, jsonValue);
|
||||
console.log(`[Storage] Saved current chat for user ${userId}, role ${roleId}:`, messages.length, 'messages');
|
||||
} catch (e) {
|
||||
console.error(`Error saving current chat for role ${roleId}:`, e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Load the current active conversation messages for a specific role and user
|
||||
*/
|
||||
async getCurrentChat(roleId: string, userId: string | number): Promise<any[]> {
|
||||
try {
|
||||
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
|
||||
const jsonValue = await AsyncStorage.getItem(key);
|
||||
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
|
||||
console.log(`[Storage] Loaded current chat for user ${userId}, role ${roleId}:`, result.length, 'messages');
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error(`Error getting current chat for role ${roleId}:`, e);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all stored chat data for a specific user
|
||||
*/
|
||||
async clearUserData(userId: string | number): Promise<void> {
|
||||
try {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const userPrefix = `:user_${userId}`;
|
||||
const userKeys = keys.filter(key => key.includes(userPrefix));
|
||||
await AsyncStorage.multiRemove(userKeys);
|
||||
console.log(`[Storage] Cleared all data for user ${userId}:`, userKeys.length, 'keys removed');
|
||||
} catch (e) {
|
||||
console.error('Error clearing user data:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear all stored chat data (all users)
|
||||
*/
|
||||
async clearAllData(): Promise<void> {
|
||||
try {
|
||||
const keys = await AsyncStorage.getAllKeys();
|
||||
const sentinelKeys = keys.filter(key => key.startsWith('@sentinel:'));
|
||||
await AsyncStorage.multiRemove(sentinelKeys);
|
||||
console.log('[Storage] Cleared all data:', sentinelKeys.length, 'keys removed');
|
||||
} catch (e) {
|
||||
console.error('Error clearing storage data:', e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Save the plaintext backup of an asset locally
|
||||
*/
|
||||
async saveAssetBackup(assetId: number, content: string, userId: string | number): Promise<void> {
|
||||
try {
|
||||
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
|
||||
await AsyncStorage.setItem(key, content);
|
||||
console.log(`[Storage] Saved asset backup for user ${userId}, asset ${assetId}`);
|
||||
} catch (e) {
|
||||
console.error(`Error saving asset backup for asset ${assetId}:`, e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Retrieve the plaintext backup of an asset locally
|
||||
*/
|
||||
async getAssetBackup(assetId: number, userId: string | number): Promise<string | null> {
|
||||
try {
|
||||
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
|
||||
return await AsyncStorage.getItem(key);
|
||||
} catch (e) {
|
||||
console.error(`Error getting asset backup for asset ${assetId}:`, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
81
src/services/vault.service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Vault Service: 为 /assets/create 生成 private_key_shard 与 content_inner_encrypted
|
||||
*
|
||||
* 流程(与后端 test_scenario / SentinelVault 一致):
|
||||
* 1. 用 SSS 生成助记词并分片 → 选一个分片作为 private_key_shard(存后端,继承时返回)
|
||||
* 2. 用助记词派生 AES 密钥,对明文做 AES-GCM 加密 → content_inner_encrypted(hex 字符串)
|
||||
*
|
||||
* 使用方式:在任意页面调用 createVaultPayload(plaintext, wordList),得到可直接传给 assetsService.createAsset 的字段。
|
||||
*/
|
||||
|
||||
import {
|
||||
generateVaultKeys,
|
||||
serializeShare,
|
||||
type SSSShare,
|
||||
type VaultKeyData,
|
||||
} from '../utils/sss';
|
||||
import { deriveKey, encryptDataGCM, bytesToHex } from '../utils/vaultCrypto';
|
||||
|
||||
export interface CreateVaultPayloadResult {
|
||||
/** 传给后端的 private_key_shard(存一个 SSS 分片的序列化字符串,如云端分片) */
|
||||
private_key_shard: string;
|
||||
/** 传给后端的 content_inner_encrypted(AES-GCM 密文的 hex) */
|
||||
content_inner_encrypted: string;
|
||||
/** 本次生成的助记词(用户需妥善保管,恢复时需任意 2 个分片) */
|
||||
mnemonic: string[];
|
||||
/** 三个分片:device / cloud / heir,可与后端返回的 server_shard 组合恢复助记词 */
|
||||
shares: SSSShare[];
|
||||
}
|
||||
|
||||
export interface CreateAssetPayloadResult {
|
||||
title: string;
|
||||
type: string;
|
||||
private_key_shard: string;
|
||||
content_inner_encrypted: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成金库:助记词 + SSS 分片 + 内层加密内容
|
||||
* @param plaintext 要加密的明文(如遗产说明、账号密码等)
|
||||
* @param wordList 助记词词表(与 sss 使用的词表一致)
|
||||
* @param shareIndexForServer 哪个分片存后端,0=device, 1=cloud, 2=heir,默认 1(云端)
|
||||
*/
|
||||
export async function createVaultPayload(
|
||||
plaintext: string,
|
||||
wordList: readonly string[],
|
||||
shareIndexForServer: 0 | 1 | 2 = 1
|
||||
): Promise<CreateVaultPayloadResult> {
|
||||
const { mnemonic, shares }: VaultKeyData = generateVaultKeys(wordList, 12);
|
||||
const mnemonicPhrase = mnemonic.join(' ');
|
||||
const key = await deriveKey(mnemonicPhrase);
|
||||
const encrypted = await encryptDataGCM(key, plaintext);
|
||||
const content_inner_encrypted = bytesToHex(encrypted);
|
||||
const shareForServer = shares[shareIndexForServer];
|
||||
const private_key_shard = serializeShare(shareForServer);
|
||||
|
||||
return {
|
||||
private_key_shard,
|
||||
content_inner_encrypted,
|
||||
mnemonic,
|
||||
shares,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成可直接用于 POST /assets/create 的请求体(含 title / type)
|
||||
*/
|
||||
export async function createAssetPayload(
|
||||
title: string,
|
||||
plaintext: string,
|
||||
wordList: readonly string[],
|
||||
assetType: string = 'note',
|
||||
shareIndexForServer: 0 | 1 | 2 = 1
|
||||
): Promise<CreateAssetPayloadResult> {
|
||||
const vault = await createVaultPayload(plaintext, wordList, shareIndexForServer);
|
||||
return {
|
||||
title,
|
||||
type: assetType,
|
||||
private_key_shard: vault.private_key_shard,
|
||||
content_inner_encrypted: vault.content_inner_encrypted,
|
||||
};
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export interface VaultAsset {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
isEncrypted: boolean;
|
||||
heirEmail?: string;
|
||||
rawData?: any; // For debug logging
|
||||
}
|
||||
|
||||
// Sentinel Types
|
||||
@@ -72,3 +74,43 @@ export interface ProtocolInfo {
|
||||
version: string;
|
||||
lastUpdated: Date;
|
||||
}
|
||||
|
||||
// Auth Types
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
public_key: string;
|
||||
is_admin: boolean;
|
||||
guale: boolean;
|
||||
tier: string;
|
||||
tier_expires_at: string;
|
||||
last_active_at: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
// AI Types
|
||||
export interface AIRole {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
systemPrompt: string;
|
||||
icon: string;
|
||||
iconFamily: string;
|
||||
}
|
||||
|
||||
22
src/utils/async_hooks_mock.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Mock for Node.js async_hooks
|
||||
* Used to fix LangGraph.js compatibility with React Native
|
||||
*/
|
||||
|
||||
export class AsyncLocalStorage {
|
||||
disable() { }
|
||||
getStore() {
|
||||
return undefined;
|
||||
}
|
||||
run(store: any, callback: (...args: any[]) => any, ...args: any[]) {
|
||||
return callback(...args);
|
||||
}
|
||||
exit(callback: (...args: any[]) => any, ...args: any[]) {
|
||||
return callback(...args);
|
||||
}
|
||||
enterWith(store: any) { }
|
||||
}
|
||||
|
||||
export default {
|
||||
AsyncLocalStorage,
|
||||
};
|
||||
202
src/utils/crypto_core.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import * as bip39 from 'bip39';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
// 定义分片类型:[x坐标, y坐标]
|
||||
export type Share = [bigint, bigint];
|
||||
|
||||
// 定义生成密钥的返回接口
|
||||
export interface VaultKeys {
|
||||
mnemonic: string;
|
||||
entropyHex: string;
|
||||
}
|
||||
|
||||
export class SentinelKeyEngine {
|
||||
// 使用第 13 个梅森素数 (2^521 - 1)
|
||||
// readonly 确保不会被修改
|
||||
private readonly PRIME: bigint = 2n ** 521n - 1n;
|
||||
|
||||
/**
|
||||
* 1. 生成原始 12 助记词 (Master Key)
|
||||
*/
|
||||
public generateVaultKeys(): VaultKeys {
|
||||
// 生成 128 位强度的助记词 (12 个单词)
|
||||
const mnemonic = bip39.generateMnemonic(128);
|
||||
|
||||
// 将助记词转为 16 进制熵 (Hex String)
|
||||
const entropyHex = bip39.mnemonicToEntropy(mnemonic);
|
||||
|
||||
return { mnemonic, entropyHex };
|
||||
}
|
||||
|
||||
public mnemonicToEntropy(mnemonic: string): string {
|
||||
return bip39.mnemonicToEntropy(mnemonic);
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. SSS (3,2) 门限分片逻辑
|
||||
* @param entropyHex - 16进制字符串 (32字符)
|
||||
*/
|
||||
public splitToShares(entropyHex: string): Share[] {
|
||||
// 将 Hex 熵转换为 BigInt
|
||||
const secretInt = BigInt('0x' + entropyHex);
|
||||
|
||||
// 生成随机系数 a,范围 [0, PRIME-1]
|
||||
const a = this.secureRandomBigInt(this.PRIME);
|
||||
|
||||
// 定义函数 f(x) = (S + a * x) % PRIME
|
||||
const f = (x: number): bigint => {
|
||||
const xBi = BigInt(x);
|
||||
return (secretInt + a * xBi) % this.PRIME;
|
||||
};
|
||||
|
||||
// 生成 3 个分片: x=1, x=2, x=3
|
||||
const share1: Share = [1n, f(1)]; // 手机分片
|
||||
const share2: Share = [2n, f(2)]; // 云端分片
|
||||
const share3: Share = [3n, f(3)]; // 传承卡分片
|
||||
|
||||
return [share1, share2, share3];
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. 恢复逻辑:拉格朗日插值还原
|
||||
* @param shareA - 第一个分片
|
||||
* @param shareB - 第二个分片
|
||||
*/
|
||||
public recoverFromShares(shareA: Share, shareB: Share): string {
|
||||
const [x1, y1] = shareA;
|
||||
const [x2, y2] = shareB;
|
||||
|
||||
// 计算分子: (x2 * y1 - x1 * y2) % PRIME
|
||||
// TS/JS 的 % 运算符对负数返回负数,需修正为正余数
|
||||
let numerator = (x2 * y1 - x1 * y2) % this.PRIME;
|
||||
if (numerator < 0n) numerator += this.PRIME;
|
||||
|
||||
// 计算分母: (x2 - x1)
|
||||
let denominator = (x2 - x1) % this.PRIME;
|
||||
if (denominator < 0n) denominator += this.PRIME;
|
||||
|
||||
// 计算分母的模逆: denominator^-1 mod PRIME
|
||||
// 费马小定理: a^(p-2) = a^-1 (mod p)
|
||||
const invDenominator = this.modPow(denominator, this.PRIME - 2n, this.PRIME);
|
||||
|
||||
// 还原常数项 S
|
||||
const secretInt = (numerator * invDenominator) % this.PRIME;
|
||||
|
||||
// 转回 Hex 字符串
|
||||
let recoveredEntropyHex = secretInt.toString(16);
|
||||
|
||||
// 补齐前导零 (Pad Start)
|
||||
// 128 bit 熵 = 16 字节 = 32 个 Hex 字符
|
||||
// 如果你的熵是 256 bit,这里需要改为 64
|
||||
recoveredEntropyHex = recoveredEntropyHex.padStart(32, '0');
|
||||
|
||||
return bip39.entropyToMnemonic(recoveredEntropyHex);
|
||||
}
|
||||
|
||||
// --- Private Helper Methods ---
|
||||
|
||||
/**
|
||||
* 生成小于 limit 的安全随机 BigInt
|
||||
*/
|
||||
private secureRandomBigInt(limit: bigint): bigint {
|
||||
// 计算需要的字节数
|
||||
const bitLength = limit.toString(2).length;
|
||||
const byteLength = Math.ceil(bitLength / 8);
|
||||
|
||||
let randomBi: bigint;
|
||||
do {
|
||||
const buf = crypto.randomBytes(byteLength);
|
||||
randomBi = BigInt('0x' + buf.toString('hex'));
|
||||
// 拒绝采样:确保结果小于 limit
|
||||
} while (randomBi >= limit);
|
||||
|
||||
return randomBi;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模幂运算: (base^exp) % modulus
|
||||
* 用于计算模逆
|
||||
*/
|
||||
private modPow(base: bigint, exp: bigint, modulus: bigint): bigint {
|
||||
let result = 1n;
|
||||
base = base % modulus;
|
||||
while (exp > 0n) {
|
||||
if (exp % 2n === 1n) result = (result * base) % modulus;
|
||||
exp = exp >> 1n; // 相当于除以 2
|
||||
base = (base * base) % modulus;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export class SentinelVault {
|
||||
private salt: Buffer;
|
||||
|
||||
constructor(salt?: string | Buffer) {
|
||||
// 默认盐值与 Python 版本保持一致
|
||||
this.salt = salt ? Buffer.from(salt) : Buffer.from('Sentinel_Salt_2026');
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 PBKDF2 将助记词转换为 AES-256 密钥 (32 bytes)
|
||||
*/
|
||||
public async deriveKey(mnemonicPhrase: string): Promise<Buffer> {
|
||||
// 1. BIP-39 助记词转种子 (遵循 BIP-39 标准)
|
||||
// Python 的 to_seed 默认返回 64 字节种子
|
||||
const seed = await bip39.mnemonicToSeed(mnemonicPhrase);
|
||||
|
||||
// 2. PBKDF2 派生密钥
|
||||
// 注意:PyCryptodome 的 PBKDF2 默认使用 HMAC-SHA1 (如未指定)
|
||||
// 为了确保与 Python 逻辑严格一致,这里使用 'sha1'
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.pbkdf2(seed, this.salt, 100000, 32, 'sha1', (err, derivedKey) => {
|
||||
if (err) reject(err);
|
||||
resolve(derivedKey);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 AES-256-GCM 模式进行加密
|
||||
*/
|
||||
public encryptData(key: Buffer, plaintext: string): Buffer {
|
||||
// GCM 模式推荐 nonce 长度,Python 默认通常为 16 字节
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
const ciphertext = Buffer.concat([
|
||||
cipher.update(plaintext, 'utf8'),
|
||||
cipher.final()
|
||||
]);
|
||||
|
||||
// 获取 GCM 认证标签 (16 bytes)
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
// 拼接结果:Nonce + Tag + Ciphertext
|
||||
return Buffer.concat([iv, tag, ciphertext]);
|
||||
}
|
||||
|
||||
/**
|
||||
* AES-256-GCM 解密
|
||||
*/
|
||||
public decryptData(key: Buffer, encryptedBlob: Buffer): string {
|
||||
try {
|
||||
// 切片提取组件
|
||||
const iv = encryptedBlob.subarray(0, 16);
|
||||
const tag = encryptedBlob.subarray(16, 32);
|
||||
const ciphertext = encryptedBlob.subarray(32);
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(ciphertext),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
} catch (error) {
|
||||
return "【解密失败】:密钥错误或数据被篡改";
|
||||
}
|
||||
}
|
||||
}
|
||||
135
src/utils/crypto_polyfill.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as ExpoCrypto from 'expo-crypto';
|
||||
import { Buffer } from 'buffer';
|
||||
import { pbkdf2 as noblePbkdf2 } from '@noble/hashes/pbkdf2';
|
||||
import { sha1 } from '@noble/hashes/sha1';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { sha512 } from '@noble/hashes/sha512';
|
||||
import { gcm } from '@noble/ciphers/aes';
|
||||
|
||||
/**
|
||||
* Node.js Crypto Polyfill for React Native
|
||||
*/
|
||||
|
||||
export function randomBytes(size: number): Buffer {
|
||||
const bytes = new Uint8Array(size);
|
||||
ExpoCrypto.getRandomValues(bytes);
|
||||
return Buffer.from(bytes);
|
||||
}
|
||||
|
||||
const hashMap: Record<string, any> = {
|
||||
sha1,
|
||||
sha256,
|
||||
sha512,
|
||||
};
|
||||
|
||||
export function pbkdf2(
|
||||
password: string | Buffer,
|
||||
salt: string | Buffer,
|
||||
iterations: number,
|
||||
keylen: number,
|
||||
digest: string,
|
||||
callback: (err: Error | null, derivedKey: Buffer) => void
|
||||
): void {
|
||||
try {
|
||||
const passwordBytes = typeof password === 'string' ? Buffer.from(password) : password;
|
||||
const saltBytes = typeof salt === 'string' ? Buffer.from(salt) : salt;
|
||||
const hasher = hashMap[digest.toLowerCase()];
|
||||
|
||||
if (!hasher) {
|
||||
throw new Error(`Unsupported digest: ${digest}`);
|
||||
}
|
||||
|
||||
const result = noblePbkdf2(hasher, passwordBytes, saltBytes, {
|
||||
c: iterations,
|
||||
dkLen: keylen,
|
||||
});
|
||||
|
||||
callback(null, Buffer.from(result));
|
||||
} catch (err) {
|
||||
callback(err as Error, Buffer.alloc(0));
|
||||
}
|
||||
}
|
||||
|
||||
// AES-GCM Implementation
|
||||
class Cipher {
|
||||
private key: Uint8Array;
|
||||
private iv: Uint8Array;
|
||||
private authTag: Buffer | null = null;
|
||||
private aesGcm: any;
|
||||
private buffer: Buffer = Buffer.alloc(0);
|
||||
|
||||
constructor(key: Buffer, iv: Buffer) {
|
||||
this.key = new Uint8Array(key);
|
||||
this.iv = new Uint8Array(iv);
|
||||
// @noble/ciphers/aes gcm takes (key, nonce)
|
||||
this.aesGcm = gcm(this.key, this.iv);
|
||||
}
|
||||
|
||||
update(data: string | Buffer, inputEncoding?: string): Buffer {
|
||||
const input = typeof data === 'string' ? Buffer.from(data, inputEncoding as any) : data;
|
||||
this.buffer = Buffer.concat([this.buffer, input]);
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
final(): Buffer {
|
||||
const result = this.aesGcm.encrypt(this.buffer);
|
||||
// @noble/ciphers returns ciphertext + tag (16 bytes)
|
||||
const tag = result.slice(-16);
|
||||
const ciphertext = result.slice(0, -16);
|
||||
this.authTag = Buffer.from(tag);
|
||||
return Buffer.from(ciphertext);
|
||||
}
|
||||
|
||||
getAuthTag(): Buffer {
|
||||
if (!this.authTag) throw new Error('Ciphers: TAG not available before final()');
|
||||
return this.authTag;
|
||||
}
|
||||
}
|
||||
|
||||
class Decipher {
|
||||
private key: Uint8Array;
|
||||
private iv: Uint8Array;
|
||||
private tag: Uint8Array | null = null;
|
||||
private aesGcm: any;
|
||||
private buffer: Buffer = Buffer.alloc(0);
|
||||
|
||||
constructor(key: Buffer, iv: Buffer) {
|
||||
this.key = new Uint8Array(key);
|
||||
this.iv = new Uint8Array(iv);
|
||||
this.aesGcm = gcm(this.key, this.iv);
|
||||
}
|
||||
|
||||
setAuthTag(tag: Buffer): void {
|
||||
this.tag = new Uint8Array(tag);
|
||||
}
|
||||
|
||||
update(data: Buffer): Buffer {
|
||||
this.buffer = Buffer.concat([this.buffer, data]);
|
||||
return Buffer.alloc(0);
|
||||
}
|
||||
|
||||
final(): Buffer {
|
||||
if (!this.tag) throw new Error('Decipher: Auth tag not set');
|
||||
// @noble/ciphers expects ciphertext then tag
|
||||
const full = new Uint8Array(this.buffer.length + this.tag.length);
|
||||
full.set(this.buffer);
|
||||
full.set(this.tag, this.buffer.length);
|
||||
|
||||
const decrypted = this.aesGcm.decrypt(full);
|
||||
return Buffer.from(decrypted);
|
||||
}
|
||||
}
|
||||
|
||||
export function createCipheriv(algorithm: string, key: Buffer, iv: Buffer): Cipher {
|
||||
if (algorithm !== 'aes-256-gcm') {
|
||||
throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`);
|
||||
}
|
||||
return new Cipher(key, iv);
|
||||
}
|
||||
|
||||
export function createDecipheriv(algorithm: string, key: Buffer, iv: Buffer): Decipher {
|
||||
if (algorithm !== 'aes-256-gcm') {
|
||||
throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`);
|
||||
}
|
||||
return new Decipher(key, iv);
|
||||
}
|
||||
6
src/utils/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Utility functions for Sentinel
|
||||
*/
|
||||
|
||||
export * from './sss';
|
||||
export * from './vaultAssets';
|
||||
268
src/utils/sss.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* Shamir's Secret Sharing (SSS) Implementation
|
||||
*
|
||||
* This implements a (3,2) threshold scheme where:
|
||||
* - Secret is split into 3 shares
|
||||
* - Any 2 shares can recover the original secret
|
||||
*
|
||||
* Correspondence with crypto_core_demo (Python):
|
||||
* - sp_trust_sharding.py: split_to_shares(), recover_from_shares()
|
||||
* - Same algorithm: f(x) = secret + a*x (mod P), Lagrange interpolation
|
||||
* - Difference: entropy conversion. Python uses BIP-39 (mnemonic.to_entropy);
|
||||
* we use custom word list index-based encoding for compatibility with
|
||||
* existing MNEMONIC_WORDS. SSS split/recover logic is identical.
|
||||
*/
|
||||
|
||||
// Use a large prime for the finite field
|
||||
// We use 2^127 - 1 (a Mersenne prime) which fits well in BigInt
|
||||
// This is smaller than the Python version's 2^521 - 1 but sufficient for our 128-bit entropy
|
||||
const PRIME = BigInt('170141183460469231731687303715884105727'); // 2^127 - 1
|
||||
|
||||
/**
|
||||
* Represents an SSS share as a coordinate point (x, y)
|
||||
*/
|
||||
export interface SSSShare {
|
||||
x: number;
|
||||
y: bigint;
|
||||
label: string; // 'device' | 'cloud' | 'heir'
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a cryptographically secure random BigInt in range [0, max)
|
||||
*/
|
||||
function secureRandomBigInt(max: bigint): bigint {
|
||||
// Get the number of bytes needed
|
||||
const byteLength = Math.ceil(max.toString(2).length / 8);
|
||||
const randomBytes = new Uint8Array(byteLength);
|
||||
|
||||
// Use crypto.getRandomValues for secure randomness
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
crypto.getRandomValues(randomBytes);
|
||||
} else {
|
||||
// Fallback for environments without crypto
|
||||
for (let i = 0; i < byteLength; i++) {
|
||||
randomBytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to BigInt
|
||||
let result = BigInt(0);
|
||||
for (let i = 0; i < randomBytes.length; i++) {
|
||||
result = (result << BigInt(8)) + BigInt(randomBytes[i]);
|
||||
}
|
||||
|
||||
// Ensure result is within range
|
||||
return result % max;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert mnemonic words to entropy (as BigInt)
|
||||
* Each word is mapped to its index, then combined into a single large number
|
||||
*/
|
||||
export function mnemonicToEntropy(words: string[], wordList: readonly string[]): bigint {
|
||||
let entropy = BigInt(0);
|
||||
const wordListLength = BigInt(wordList.length);
|
||||
|
||||
for (const word of words) {
|
||||
const index = wordList.indexOf(word);
|
||||
if (index === -1) {
|
||||
throw new Error(`Word "${word}" not found in word list`);
|
||||
}
|
||||
entropy = entropy * wordListLength + BigInt(index);
|
||||
}
|
||||
|
||||
return entropy;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert entropy back to mnemonic words
|
||||
*/
|
||||
export function entropyToMnemonic(entropy: bigint, wordCount: number, wordList: readonly string[]): string[] {
|
||||
const words: string[] = [];
|
||||
const wordListLength = BigInt(wordList.length);
|
||||
let remaining = entropy;
|
||||
|
||||
for (let i = 0; i < wordCount; i++) {
|
||||
const index = Number(remaining % wordListLength);
|
||||
words.unshift(wordList[index]);
|
||||
remaining = remaining / wordListLength;
|
||||
}
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modular inverse using extended Euclidean algorithm
|
||||
* Returns x such that (a * x) % p === 1
|
||||
*/
|
||||
function modInverse(a: bigint, p: bigint): bigint {
|
||||
let [oldR, r] = [a % p, p];
|
||||
let [oldS, s] = [BigInt(1), BigInt(0)];
|
||||
|
||||
while (r !== BigInt(0)) {
|
||||
const quotient = oldR / r;
|
||||
[oldR, r] = [r, oldR - quotient * r];
|
||||
[oldS, s] = [s, oldS - quotient * s];
|
||||
}
|
||||
|
||||
// Ensure positive result
|
||||
return ((oldS % p) + p) % p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modular arithmetic helper to ensure positive results
|
||||
*/
|
||||
function mod(n: bigint, p: bigint): bigint {
|
||||
return ((n % p) + p) % p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a secret into 3 shares using SSS (3,2) threshold scheme
|
||||
*
|
||||
* Uses linear polynomial: f(x) = secret + a*x (mod p)
|
||||
* where 'a' is a random coefficient
|
||||
*
|
||||
* Any 2 points on this line can recover the y-intercept (secret)
|
||||
*/
|
||||
export function splitSecret(secret: bigint): SSSShare[] {
|
||||
// Generate random coefficient for the polynomial
|
||||
const a = secureRandomBigInt(PRIME);
|
||||
|
||||
// Polynomial: f(x) = secret + a*x (mod PRIME)
|
||||
const f = (x: number): bigint => {
|
||||
return mod(secret + a * BigInt(x), PRIME);
|
||||
};
|
||||
|
||||
// Generate 3 shares at x = 1, 2, 3
|
||||
return [
|
||||
{ x: 1, y: f(1), label: 'device' },
|
||||
{ x: 2, y: f(2), label: 'cloud' },
|
||||
{ x: 3, y: f(3), label: 'heir' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the secret from any 2 shares using Lagrange interpolation
|
||||
*
|
||||
* For 2 points (x1, y1) and (x2, y2), the secret (y-intercept) is:
|
||||
* S = (x2*y1 - x1*y2) / (x2 - x1) (mod p)
|
||||
*/
|
||||
export function recoverSecret(shareA: SSSShare, shareB: SSSShare): bigint {
|
||||
const { x: x1, y: y1 } = shareA;
|
||||
const { x: x2, y: y2 } = shareB;
|
||||
|
||||
// Numerator: x2*y1 - x1*y2
|
||||
const numerator = mod(
|
||||
BigInt(x2) * y1 - BigInt(x1) * y2,
|
||||
PRIME
|
||||
);
|
||||
|
||||
// Denominator: x2 - x1
|
||||
const denominator = mod(BigInt(x2 - x1), PRIME);
|
||||
|
||||
// Division in modular arithmetic = multiply by modular inverse
|
||||
const invDenominator = modInverse(denominator, PRIME);
|
||||
|
||||
return mod(numerator * invDenominator, PRIME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a share for display (truncated for readability)
|
||||
* Shows first 8 and last 4 characters of the y-value
|
||||
*/
|
||||
export function formatShareForDisplay(share: SSSShare): string {
|
||||
const yStr = share.y.toString();
|
||||
if (yStr.length <= 16) {
|
||||
return `(${share.x}, ${yStr})`;
|
||||
}
|
||||
return `(${share.x}, ${yStr.slice(0, 8)}...${yStr.slice(-4)})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a share as a compact display string (for UI cards)
|
||||
* Returns a shorter format showing the share index and a hash-like preview
|
||||
*/
|
||||
export function formatShareCompact(share: SSSShare): string {
|
||||
const yStr = share.y.toString();
|
||||
// Create a "fingerprint" from the y value
|
||||
const fingerprint = yStr.slice(0, 4) + '-' + yStr.slice(4, 8) + '-' + yStr.slice(-4);
|
||||
return fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a share to a string for storage/transmission
|
||||
*/
|
||||
export function serializeShare(share: SSSShare): string {
|
||||
return JSON.stringify({
|
||||
x: share.x,
|
||||
y: share.y.toString(),
|
||||
label: share.label,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize a share from a string
|
||||
*/
|
||||
export function deserializeShare(str: string): SSSShare {
|
||||
const parsed = JSON.parse(str);
|
||||
return {
|
||||
x: parsed.x,
|
||||
y: BigInt(parsed.y),
|
||||
label: parsed.label,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to generate mnemonic and SSS shares
|
||||
* This is the entry point for the vault initialization flow
|
||||
*/
|
||||
export interface VaultKeyData {
|
||||
mnemonic: string[];
|
||||
shares: SSSShare[];
|
||||
entropy: bigint;
|
||||
}
|
||||
|
||||
export function generateVaultKeys(
|
||||
wordList: readonly string[],
|
||||
wordCount: number = 12
|
||||
): VaultKeyData {
|
||||
// Generate random mnemonic
|
||||
const mnemonic: string[] = [];
|
||||
for (let i = 0; i < wordCount; i++) {
|
||||
const index = Math.floor(Math.random() * wordList.length);
|
||||
mnemonic.push(wordList[index]);
|
||||
}
|
||||
|
||||
// Convert to entropy
|
||||
const entropy = mnemonicToEntropy(mnemonic, wordList);
|
||||
|
||||
// Split into shares
|
||||
const shares = splitSecret(entropy);
|
||||
|
||||
return { mnemonic, shares, entropy };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that shares can recover the original entropy
|
||||
* Useful for testing and validation
|
||||
*/
|
||||
export function verifyShares(
|
||||
shares: SSSShare[],
|
||||
originalEntropy: bigint
|
||||
): boolean {
|
||||
// Test all 3 combinations of 2 shares
|
||||
const combinations = [
|
||||
[shares[0], shares[1]], // Device + Cloud
|
||||
[shares[1], shares[2]], // Cloud + Heir
|
||||
[shares[0], shares[2]], // Device + Heir
|
||||
];
|
||||
|
||||
for (const [a, b] of combinations) {
|
||||
const recovered = recoverSecret(a, b);
|
||||
if (recovered !== originalEntropy) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
76
src/utils/token_utils.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Token Utilities
|
||||
*
|
||||
* Shared logic for trimming messages to stay within token limits.
|
||||
*/
|
||||
|
||||
import { BaseMessage, SystemMessage } from "@langchain/core/messages";
|
||||
import { AIMessage as ServiceAIMessage } from "../services/ai.service";
|
||||
|
||||
export const TOKEN_LIMIT = 10000;
|
||||
const CHARS_PER_TOKEN = 3; // Conservative estimate: 1 token ≈ 3 chars
|
||||
export const MAX_CHARS = TOKEN_LIMIT * CHARS_PER_TOKEN;
|
||||
|
||||
/**
|
||||
* Trims LangChain messages to fit within token limit
|
||||
*/
|
||||
export function trimLangChainMessages(messages: BaseMessage[]): BaseMessage[] {
|
||||
let totalLength = 0;
|
||||
const trimmed: BaseMessage[] = [];
|
||||
|
||||
// Always keep the system message if it's at the start
|
||||
let systemMsg: BaseMessage | null = null;
|
||||
if (messages.length > 0 && (messages[0] instanceof SystemMessage || (messages[0] as any)._getType?.() === 'system')) {
|
||||
systemMsg = messages[0];
|
||||
totalLength += systemMsg.content.toString().length;
|
||||
}
|
||||
|
||||
// Iterate backwards and add messages until we hit the char limit
|
||||
for (let i = messages.length - 1; i >= (systemMsg ? 1 : 0); i--) {
|
||||
const msg = messages[i];
|
||||
const len = msg.content.toString().length;
|
||||
|
||||
if (totalLength + len > MAX_CHARS) break;
|
||||
|
||||
trimmed.unshift(msg);
|
||||
totalLength += len;
|
||||
}
|
||||
|
||||
if (systemMsg) {
|
||||
trimmed.unshift(systemMsg);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trims internal AIMessage format messages to fit within token limit
|
||||
*/
|
||||
export function trimInternalMessages(messages: ServiceAIMessage[]): ServiceAIMessage[] {
|
||||
let totalLength = 0;
|
||||
const trimmed: ServiceAIMessage[] = [];
|
||||
|
||||
// Always keep the system message if it's at the start
|
||||
let systemMsg: ServiceAIMessage | null = null;
|
||||
if (messages.length > 0 && messages[0].role === 'system') {
|
||||
systemMsg = messages[0];
|
||||
totalLength += systemMsg.content.length;
|
||||
}
|
||||
|
||||
// Iterate backwards and add messages until we hit the char limit
|
||||
for (let i = messages.length - 1; i >= (systemMsg ? 1 : 0); i--) {
|
||||
const msg = messages[i];
|
||||
const len = msg.content.length;
|
||||
|
||||
if (totalLength + len > MAX_CHARS) break;
|
||||
|
||||
trimmed.unshift(msg);
|
||||
totalLength += len;
|
||||
}
|
||||
|
||||
if (systemMsg) {
|
||||
trimmed.unshift(systemMsg);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
71
src/utils/vaultAssets.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Vault assets: API ↔ UI mapping and initial mock data.
|
||||
* Used by useVaultAssets and VaultScreen for /assets/get and /assets/create flows.
|
||||
*/
|
||||
|
||||
import type { VaultAsset, VaultAssetType } from '../types';
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/** Shape returned by GET /assets/get (backend AssetOut) */
|
||||
export interface ApiAsset {
|
||||
id: number;
|
||||
title: string;
|
||||
type?: string;
|
||||
author_id?: number;
|
||||
private_key_shard?: string;
|
||||
content_outer_encrypted?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
heir_id?: number;
|
||||
heir_email?: string;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Constants
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export const VAULT_ASSET_TYPES: VaultAssetType[] = [
|
||||
'game_account',
|
||||
'private_key',
|
||||
'document',
|
||||
'photo',
|
||||
'will',
|
||||
'custom',
|
||||
];
|
||||
|
||||
export const initialVaultAssets: VaultAsset[] = [];
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Mapping
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Map backend API asset to VaultAsset for UI.
|
||||
*/
|
||||
export function mapApiAssetToVaultAsset(api: ApiAsset): VaultAsset {
|
||||
const type: VaultAssetType =
|
||||
api.type && VAULT_ASSET_TYPES.includes(api.type as VaultAssetType)
|
||||
? (api.type as VaultAssetType)
|
||||
: 'custom';
|
||||
return {
|
||||
id: String(api.id),
|
||||
type,
|
||||
label: api.title,
|
||||
createdAt: api.created_at ? new Date(api.created_at) : new Date(),
|
||||
updatedAt: api.updated_at ? new Date(api.updated_at) : new Date(),
|
||||
isEncrypted: true,
|
||||
heirEmail: api.heir_email,
|
||||
rawData: api,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map array of API assets to VaultAsset[].
|
||||
*/
|
||||
export function mapApiAssetsToVaultAssets(apiList: ApiAsset[]): VaultAsset[] {
|
||||
return apiList.map(mapApiAssetToVaultAsset);
|
||||
}
|
||||
|
||||
107
src/utils/vaultCrypto.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Vault crypto: PBKDF2 key derivation + AES-256-GCM encrypt/decrypt.
|
||||
* Matches backend SentinelVault semantics (PBKDF2 from mnemonic, AES-GCM).
|
||||
* Uses Web Crypto API (crypto.subtle). Requires secure context / React Native polyfill if needed.
|
||||
*/
|
||||
|
||||
const SALT = new TextEncoder().encode('Sentinel_Salt_2026');
|
||||
const PBKDF2_ITERATIONS = 100000;
|
||||
const AES_KEY_LEN = 256;
|
||||
const GCM_IV_LEN = 16;
|
||||
const GCM_TAG_LEN = 16;
|
||||
|
||||
function getCrypto(): Crypto {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) return crypto;
|
||||
throw new Error('vaultCrypto: crypto.subtle not available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a 32-byte AES key from mnemonic phrase (space-separated words).
|
||||
*/
|
||||
export async function deriveKey(mnemonicPhrase: string, salt: Uint8Array = SALT): Promise<ArrayBuffer> {
|
||||
const crypto = getCrypto();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
new TextEncoder().encode(mnemonicPhrase),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveBits']
|
||||
);
|
||||
const saltBuf = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer;
|
||||
const bits = await crypto.subtle.deriveBits(
|
||||
{
|
||||
name: 'PBKDF2',
|
||||
salt: saltBuf,
|
||||
iterations: PBKDF2_ITERATIONS,
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
keyMaterial,
|
||||
AES_KEY_LEN
|
||||
);
|
||||
return bits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt plaintext with AES-256-GCM. Returns nonce(16) + tag(16) + ciphertext (matches Python SentinelVault).
|
||||
*/
|
||||
export async function encryptDataGCM(key: ArrayBuffer, plaintext: string): Promise<Uint8Array> {
|
||||
const crypto = getCrypto();
|
||||
const iv = crypto.getRandomValues(new Uint8Array(GCM_IV_LEN));
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
key,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const ciphertextWithTag = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv, tagLength: GCM_TAG_LEN * 8 },
|
||||
cryptoKey,
|
||||
encoded
|
||||
);
|
||||
const out = new Uint8Array(iv.length + ciphertextWithTag.byteLength);
|
||||
out.set(iv, 0);
|
||||
out.set(new Uint8Array(ciphertextWithTag), iv.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt blob from encryptDataGCM (nonce(16) + ciphertext+tag).
|
||||
*/
|
||||
export async function decryptDataGCM(key: ArrayBuffer, blob: Uint8Array): Promise<string> {
|
||||
const crypto = getCrypto();
|
||||
const iv = blob.subarray(0, GCM_IV_LEN);
|
||||
const ciphertextWithTag = blob.subarray(GCM_IV_LEN);
|
||||
const ivBuf = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer;
|
||||
const ctBuf = ciphertextWithTag.buffer.slice(
|
||||
ciphertextWithTag.byteOffset,
|
||||
ciphertextWithTag.byteOffset + ciphertextWithTag.byteLength
|
||||
) as ArrayBuffer;
|
||||
const cryptoKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
key,
|
||||
{ name: 'AES-GCM' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
const dec = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: ivBuf, tagLength: GCM_TAG_LEN * 8 },
|
||||
cryptoKey,
|
||||
ctBuf
|
||||
);
|
||||
return new TextDecoder().decode(dec);
|
||||
}
|
||||
|
||||
export function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function hexToBytes(hex: string): Uint8Array {
|
||||
const len = hex.length / 2;
|
||||
const out = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
return out;
|
||||
}
|
||||