diff --git a/.windsurf/workflows/run-code-review.md b/.windsurf/workflows/run-code-review.md new file mode 100644 index 00000000..8b7db272 --- /dev/null +++ b/.windsurf/workflows/run-code-review.md @@ -0,0 +1,86 @@ +--- +description: Advanced code review for PRs across ByteLyst workspace repos +--- + +# Advanced Code Review + +Your task is to find all potential bugs and code improvements in the code +changes. Focus on: + +1. Logic errors and incorrect behavior +2. Edge cases that aren't handled +3. Null/undefined reference issues +4. Race conditions or concurrency issues +5. Security vulnerabilities +6. Improper resource management or resource leaks +7. API contract violations +8. Incorrect caching behavior, including cache staleness issues, cache + key-related bugs, incorrect cache invalidation, and ineffective caching +9. Violations of existing code patterns or conventions +10. Duplicate code + +Make sure to: + +1. If you find any pre-existing bugs in the code, you should also report those + since it's important for us maintain general code quality for the user. +2. Do NOT report issues that are speculative or low-confidence. All your + conclusions should be based on a complete understanding of the codebase. +3. Remember that if you were given a specific git commit, it may not be checked + out and local code states may be different. + +## Scope + +All code across the ByteLyst workspace repos: + +- **learning_ai_common_plat** - Shared platform packages and services + - packages/ - @bytelyst/\* shared libraries + - services/ - platform-service, extraction-service + - dashboards/ - admin-web, tracker-web +- **Product repos** - Individual product backends and applications + - learning_voice_ai_agent (LysnrAI) + - learning_multimodal_memory_agents (MindLyst) + - learning_ai_clock (ChronoMind) + - learning_ai_fastgap (NomGap) + - learning_ai_flowmonk (FlowMonk) + - learning_ai_jarvis_jr (JarvisJr) + - learning_ai_peakpulse (PeakPulse) + - learning_ai_notes (NoteLett) + +## Domain Context + +This is a multi-product ecosystem with shared platform services. Key architectural patterns: + +- **Platform services** (Fastify 5, TypeScript ESM) provide auth, telemetry, feature flags, etc. +- **Shared packages** (@bytelyst/\*) eliminate duplication across products +- **Product backends** handle domain-specific logic (port 4010-4017) +- **Web apps** use Next.js 16 + React 19 +- **Mobile apps** use native platforms (SwiftUI, Jetpack Compose, React Native) + +## Style + +Make sure the code follows existing conventions: + +- TypeScript: ESM, strict types, Zod validation +- Services: Fastify 5 with types.ts → repository.ts → routes.ts pattern +- Cosmos DB: All documents include productId field +- No console.log in production (use req.log/app.log in Fastify, structlog in Python) +- Commit messages: type(scope): description +- Colors: Use design tokens from @bytelyst/design-tokens, never hardcode + +## Target Branch + +Measure against the main branch of the respective repo since that will be the branch we will merge into. + +## Comments + +Do not make code changes directly. Instead, suggest changes so the reviewer can evaluate them manually. + +## Grammar + +Do not include em dash in any outputs. + +## Summary + +At the end, provide a numbered list of all potential issues found. Each issue should have a number so it can be referred to easily (e.g. "3"). + +Also include a summary and explanation of the change of the PR or diff in general from the target branch. Use mermaid diagrams, anecdotes, and any other format you see fit. diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/.last-refresh.log b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/.last-refresh.log index 9f67a0b1..b5cd3168 100644 --- a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/.last-refresh.log +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/.last-refresh.log @@ -1,9 +1,9 @@ -Last refresh: 2026-03-11T14:36:06Z (2026-03-11 07:36:06 PDT) -Cascade conversations: 50 (297M) -Memories: 77 +Last refresh: 2026-03-12T19:01:41Z (2026-03-12 12:01:41 PDT) +Cascade conversations: 50 (370M) +Memories: 80 Implicit context: 20 -Code tracker dirs: 190 -File edit history: 2782 entries +Code tracker dirs: 136 +File edit history: 2908 entries Workspace storage: 29 workspaces Repo docs: 7 files across 2 repos -Repo workflows: 40 files across 9 repos +Repo workflows: 42 files across 10 repos diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_common_plat/run-code-review.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_common_plat/run-code-review.md new file mode 100644 index 00000000..8b7db272 --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_common_plat/run-code-review.md @@ -0,0 +1,86 @@ +--- +description: Advanced code review for PRs across ByteLyst workspace repos +--- + +# Advanced Code Review + +Your task is to find all potential bugs and code improvements in the code +changes. Focus on: + +1. Logic errors and incorrect behavior +2. Edge cases that aren't handled +3. Null/undefined reference issues +4. Race conditions or concurrency issues +5. Security vulnerabilities +6. Improper resource management or resource leaks +7. API contract violations +8. Incorrect caching behavior, including cache staleness issues, cache + key-related bugs, incorrect cache invalidation, and ineffective caching +9. Violations of existing code patterns or conventions +10. Duplicate code + +Make sure to: + +1. If you find any pre-existing bugs in the code, you should also report those + since it's important for us maintain general code quality for the user. +2. Do NOT report issues that are speculative or low-confidence. All your + conclusions should be based on a complete understanding of the codebase. +3. Remember that if you were given a specific git commit, it may not be checked + out and local code states may be different. + +## Scope + +All code across the ByteLyst workspace repos: + +- **learning_ai_common_plat** - Shared platform packages and services + - packages/ - @bytelyst/\* shared libraries + - services/ - platform-service, extraction-service + - dashboards/ - admin-web, tracker-web +- **Product repos** - Individual product backends and applications + - learning_voice_ai_agent (LysnrAI) + - learning_multimodal_memory_agents (MindLyst) + - learning_ai_clock (ChronoMind) + - learning_ai_fastgap (NomGap) + - learning_ai_flowmonk (FlowMonk) + - learning_ai_jarvis_jr (JarvisJr) + - learning_ai_peakpulse (PeakPulse) + - learning_ai_notes (NoteLett) + +## Domain Context + +This is a multi-product ecosystem with shared platform services. Key architectural patterns: + +- **Platform services** (Fastify 5, TypeScript ESM) provide auth, telemetry, feature flags, etc. +- **Shared packages** (@bytelyst/\*) eliminate duplication across products +- **Product backends** handle domain-specific logic (port 4010-4017) +- **Web apps** use Next.js 16 + React 19 +- **Mobile apps** use native platforms (SwiftUI, Jetpack Compose, React Native) + +## Style + +Make sure the code follows existing conventions: + +- TypeScript: ESM, strict types, Zod validation +- Services: Fastify 5 with types.ts → repository.ts → routes.ts pattern +- Cosmos DB: All documents include productId field +- No console.log in production (use req.log/app.log in Fastify, structlog in Python) +- Commit messages: type(scope): description +- Colors: Use design tokens from @bytelyst/design-tokens, never hardcode + +## Target Branch + +Measure against the main branch of the respective repo since that will be the branch we will merge into. + +## Comments + +Do not make code changes directly. Instead, suggest changes so the reviewer can evaluate them manually. + +## Grammar + +Do not include em dash in any outputs. + +## Summary + +At the end, provide a numbered list of all potential issues found. Each issue should have a number so it can be referred to easily (e.g. "3"). + +Also include a summary and explanation of the change of the PR or diff in general from the target branch. Use mermaid diagrams, anecdotes, and any other format you see fit. diff --git a/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_mac_tooling/network-audit.md b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_mac_tooling/network-audit.md new file mode 100644 index 00000000..b5ac463d --- /dev/null +++ b/__LOCAL_LLMs/AI_IDE_CHAT_HISTORY/WINDSURF/repo_workflows/learning_ai_mac_tooling/network-audit.md @@ -0,0 +1,72 @@ +--- +description: Run a network transfer audit for a time window and generate an interactive HTML report +--- + +# Network Transfer Audit Workflow + +The primary interface for running audits is the **Sensor Audit Dashboard**. + +## Steps + +1. **Ensure the dashboard is running** + +```bash +/Users/sd9235/code/mygh/learning_ai_mac_tooling/audit-ctl.sh start +``` + +2. **Open the dashboard** + +```bash +/Users/sd9235/code/mygh/learning_ai_mac_tooling/audit-ctl.sh open +``` + +Navigate to **Run Audit** in the sidebar. + +3. **Choose a mode in the dashboard UI**: + - **One-shot Audit** — Analyze past network activity for a time window + - **Monitor** — Actively monitor transfers for a duration, then report + - **Continuous Collection** — Long-running data collection into SQLite + +4. **Click Start Audit** — Output streams in real-time. When done, click **Open Report** to view the HTML report. + +5. **View reports** — Go to the **Reports** page in the sidebar to see all generated HTML reports. + +6. **Browse sessions** — Go to **Sessions** to explore collected data and sensor samples. Click **Generate Report** on any session to create an HTML report from it. + +## CLI Fallback + +For headless or SSH scenarios, the CLI still works: + +``` +python3 tools/network_transfer_audit.py [TIME] [OPTIONS] + +Time (pick one, required): + --last DURATION Relative: 30m, 2h, 1d, 7d + --from DATETIME Absolute start: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM" + +Options: + --to DATETIME End time (default: now) + --live Include live connection snapshot + --monitor DURATION Monitor transfers for DURATION (5m, 10m, 1h) before report + --json Output as JSON + --html FILE Generate interactive HTML report + +Continuous Collection: + --collect DURATION Collect data for DURATION (e.g. 12h, 1d) into SQLite + --interval SECONDS Collection interval (default: 300 = 5 min) + +Query Collected Data: + --data SESSION_ID Query collected data from a session + --sessions List all collected sessions +``` + +## Management Script + +```bash +./audit-ctl.sh start # Start dashboard (API + UI on port 8742) +./audit-ctl.sh stop # Stop dashboard +./audit-ctl.sh status # Check health + stats +./audit-ctl.sh collect 12h # CLI collection shortcut +./audit-ctl.sh install # Auto-start on login via launchd +./audit-ctl.sh open # Open dashboard in browser +``` diff --git a/packages/auth-client/src/__tests__/smartauth.test.ts b/packages/auth-client/src/__tests__/smartauth.test.ts index 68117c4d..e7c5f179 100644 --- a/packages/auth-client/src/__tests__/smartauth.test.ts +++ b/packages/auth-client/src/__tests__/smartauth.test.ts @@ -59,6 +59,7 @@ describe('@bytelyst/auth-client — SmartAuth', () => { expect(opts.method).toBe('POST'); const body = JSON.parse(opts.body); expect(body.idToken).toBe('google-id-token-123'); + expect(body.productId).toBe('testapp'); }); it('returns MFA challenge when mfaRequired is true', async () => { @@ -138,14 +139,16 @@ describe('@bytelyst/auth-client — SmartAuth', () => { describe('getProviders', () => { it('calls GET /auth/providers', async () => { - const providers = [ - { - provider: 'google', - email: 'g@gmail.com', - linkedAt: '2025-01-01T00:00:00Z', - lastUsedAt: null, - }, - ]; + const providers = { + providers: [ + { + provider: 'google', + email: 'g@gmail.com', + linkedAt: '2025-01-01T00:00:00Z', + lastUsedAt: null, + }, + ], + }; globalThis.fetch = mockFetchResponse(providers); const client = createAuthClient({ @@ -238,10 +241,10 @@ describe('@bytelyst/auth-client — SmartAuth', () => { }); describe('setupTotp', () => { - it('calls POST /auth/mfa/totp/setup', async () => { + it('calls POST /auth/mfa/setup', async () => { const setup = { + secret: 'ABCDEFGH', otpauthUri: 'otpauth://totp/Test?secret=ABC', - qrDataUrl: 'data:image/png;base64,...', recoveryCodes: ['code1', 'code2'], }; globalThis.fetch = mockFetchResponse(setup); @@ -256,10 +259,11 @@ describe('@bytelyst/auth-client — SmartAuth', () => { const result = await client.setupTotp(); expect(result.otpauthUri).toContain('otpauth://'); + expect(result.secret).toBe('ABCDEFGH'); expect(result.recoveryCodes).toHaveLength(2); const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/mfa/totp/setup'); + expect(url).toBe('http://localhost:4003/api/auth/mfa/setup'); }); }); @@ -284,18 +288,20 @@ describe('@bytelyst/auth-client — SmartAuth', () => { // ── Phase 3: Passkeys ───────────────────────────── describe('listPasskeys', () => { - it('calls GET /auth/passkeys', async () => { - const passkeys = [ - { - id: 'pk1', - friendlyName: 'MacBook', - deviceType: 'platform', - backedUp: true, - lastUsedAt: null, - createdAt: '2025-01-01T00:00:00Z', - }, - ]; - globalThis.fetch = mockFetchResponse(passkeys); + it('calls GET /auth/passkeys and unwraps response', async () => { + const data = { + passkeys: [ + { + credentialId: 'pk1', + friendlyName: 'MacBook', + deviceType: 'singleDevice', + backedUp: true, + lastUsedAt: '2025-01-01T00:00:00Z', + createdAt: '2025-01-01T00:00:00Z', + }, + ], + }; + globalThis.fetch = mockFetchResponse(data); const client = createAuthClient({ baseUrl: 'http://localhost:4003/api', @@ -307,6 +313,7 @@ describe('@bytelyst/auth-client — SmartAuth', () => { const result = await client.listPasskeys(); expect(result).toHaveLength(1); expect(result[0].friendlyName).toBe('MacBook'); + expect(result[0].credentialId).toBe('pk1'); }); }); @@ -338,19 +345,22 @@ describe('@bytelyst/auth-client — SmartAuth', () => { // ── Phase 3: Devices ────────────────────────────── describe('listDevices', () => { - it('calls GET /auth/devices', async () => { - const devices = [ - { - id: 'd1', - name: 'Chrome on Mac', - platform: 'web', - trustLevel: 'trusted', - trustExpiresAt: '2025-04-01T00:00:00Z', - lastLoginAt: '2025-01-01T00:00:00Z', - createdAt: '2025-01-01T00:00:00Z', - }, - ]; - globalThis.fetch = mockFetchResponse(devices); + it('calls GET /auth/devices and unwraps response', async () => { + const data = { + devices: [ + { + fingerprint: 'fp-abc', + trustLevel: 'trusted', + deviceInfo: { platform: 'web' }, + lastIp: '1.2.3.4', + trustExpiresAt: '2025-04-01T00:00:00Z', + createdAt: '2025-01-01T00:00:00Z', + lastSeenAt: '2025-01-01T00:00:00Z', + isTrusted: true, + }, + ], + }; + globalThis.fetch = mockFetchResponse(data); const client = createAuthClient({ baseUrl: 'http://localhost:4003/api', @@ -362,11 +372,12 @@ describe('@bytelyst/auth-client — SmartAuth', () => { const result = await client.listDevices(); expect(result).toHaveLength(1); expect(result[0].trustLevel).toBe('trusted'); + expect(result[0].fingerprint).toBe('fp-abc'); }); }); - describe('revokeDevice', () => { - it('calls DELETE /auth/devices/:id', async () => { + describe('trustDevice', () => { + it('calls POST /auth/devices/trust with fingerprint and trustLevel', async () => { globalThis.fetch = mockFetchResponse({}); const client = createAuthClient({ @@ -376,14 +387,56 @@ describe('@bytelyst/auth-client — SmartAuth', () => { }); client.setTokens('tok', 'ref'); - await client.revokeDevice('device-xyz'); + await client.trustDevice('fp-abc', 'trusted', { platform: 'web' }); const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; - expect(url).toBe('http://localhost:4003/api/auth/devices/device-xyz'); + expect(url).toBe('http://localhost:4003/api/auth/devices/trust'); + expect(opts.method).toBe('POST'); + const body = JSON.parse(opts.body); + expect(body.fingerprint).toBe('fp-abc'); + expect(body.trustLevel).toBe('trusted'); + expect(body.deviceInfo).toEqual({ platform: 'web' }); + }); + }); + + describe('revokeDevice', () => { + it('calls DELETE /auth/devices/:fingerprint', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.revokeDevice('fp-xyz'); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/devices/fp-xyz'); expect(opts.method).toBe('DELETE'); }); }); + describe('revokeAllDevices', () => { + it('calls POST /auth/devices/revoke-all', async () => { + globalThis.fetch = mockFetchResponse({}); + + const client = createAuthClient({ + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + storage, + }); + client.setTokens('tok', 'ref'); + + await client.revokeAllDevices(); + + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('http://localhost:4003/api/auth/devices/revoke-all'); + expect(opts.method).toBe('POST'); + }); + }); + // ── Phase 5B: Admin security ────────────────────── describe('getSecurityOverview', () => { diff --git a/packages/auth-client/src/types.ts b/packages/auth-client/src/types.ts index d26b3168..3f760b5a 100644 --- a/packages/auth-client/src/types.ts +++ b/packages/auth-client/src/types.ts @@ -51,8 +51,8 @@ export interface MfaRequiredResult { } export interface TotpSetupResult { + secret: string; otpauthUri: string; - qrDataUrl: string; recoveryCodes: string[]; } @@ -70,33 +70,37 @@ export interface AuthProvider { } export interface Passkey { - id: string; + credentialId: string; friendlyName: string; - deviceType: 'platform' | 'cross-platform'; + deviceType: string; backedUp: boolean; - lastUsedAt: string | null; + lastUsedAt: string; createdAt: string; } export interface Device { - id: string; - name: string; - platform: string; + fingerprint: string; trustLevel: 'trusted' | 'remembered' | 'unknown'; - trustExpiresAt: string | null; - lastIp: string; - lastLoginAt: string; + deviceInfo: Record; + lastIp?: string; + lastLocation?: string; + trustExpiresAt: string; createdAt: string; + lastSeenAt: string; + isTrusted: boolean; } export interface LoginEventInfo { id: string; - eventType: string; + result: string; method: string; ip: string; - userAgent: string; + userAgent?: string; + fingerprint?: string; + location?: string; + riskLevel: string; riskScore: number; - riskFactors: string[]; + riskFlags: string[]; createdAt: string; } @@ -136,7 +140,7 @@ export interface AuthClient { verifyMfa(challengeToken: string, code: string, method: 'totp' | 'recovery'): Promise; setupTotp(): Promise; verifyTotpSetup(code: string): Promise; - disableMfa(): Promise; + disableMfa(code: string): Promise; getMfaStatus(): Promise; regenerateRecoveryCodes(): Promise<{ codes: string[] }>; @@ -150,8 +154,12 @@ export interface AuthClient { // ── Devices ───────────────────────────────────── listDevices(): Promise; - trustDevice(): Promise; - revokeDevice(deviceId: string): Promise; + trustDevice( + fingerprint: string, + trustLevel: 'trusted' | 'remembered', + deviceInfo?: Record + ): Promise; + revokeDevice(fingerprint: string): Promise; revokeAllDevices(): Promise; // ── Step-up auth ──────────────────────────────── diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt index 4824ea4a..b441c85b 100644 --- a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt @@ -129,6 +129,51 @@ class BLAuthClient( val stepUpToken: String, ) + // ── Phase 5C–5E types ───────────────────────────────────── + + @Serializable + data class TotpSecret( + val secret: String, + val issuer: String, + val accountName: String, + val digits: Int = 6, + val period: Int = 30, + val algorithm: String = "SHA1", + ) + + @Serializable + data class PushApproval( + val id: String, + val requestProductId: String, + val requestPlatform: String, + val requestIp: String, + val requestGeo: Geo? = null, + val createdAt: String, + val expiresAt: String, + ) + + @Serializable + data class PushApprovalResponse( + val id: String, + val status: String, + val respondedAt: String? = null, + ) + + @Serializable + data class QrChallenge( + val id: String, + val challengeToken: String, + val expiresAt: String, + ) + + @Serializable + data class QrStatus( + val status: String, + val accessToken: String? = null, + val refreshToken: String? = null, + val user: AuthUser? = null, + ) + /** Exception when MFA is required after login. */ class MfaRequiredException(val challenge: MfaChallenge) : Exception("MFA required") @@ -511,6 +556,38 @@ class BLAuthClient( return client.json.decodeFromString>(response) } + // ── TOTP Secret Retrieval (Phase 5C) ───────────────────── + + /** Get the decrypted TOTP secret for local code generation (auth app). */ + suspend fun getTotpSecret(): TotpSecret { + val response = client.request("GET", "/api/auth/mfa/totp/secret") + return client.json.decodeFromString(response) + } + + // ── Push Approvals (Phase 5D) ──────────────────────────── + + /** List pending push MFA approvals for the current user. */ + suspend fun getPendingApprovals(): List { + val response = client.request("GET", "/api/auth/mfa/push/pending") + return client.json.decodeFromString>(response) + } + + /** Respond to a push MFA approval (approve or deny). */ + suspend fun respondToApproval(approvalId: String, action: String): PushApprovalResponse { + val body = encodeMap(mapOf("action" to action)) + val response = client.request("POST", "/api/auth/mfa/push/$approvalId/respond", body) + return client.json.decodeFromString(response) + } + + // ── QR Auth (Phase 5E) ─────────────────────────────────── + + /** Confirm a QR login challenge from the auth app. */ + suspend fun confirmQrLogin(challengeToken: String): MessageResponse { + val body = encodeMap(mapOf("challengeToken" to challengeToken)) + val response = client.request("POST", "/api/auth/qr/confirm", body) + return client.json.decodeFromString(response) + } + // ── Session restore ───────────────────────────────────── /** diff --git a/packages/react-auth/src/auth-context.tsx b/packages/react-auth/src/auth-context.tsx index d513a13b..68e89f31 100644 --- a/packages/react-auth/src/auth-context.tsx +++ b/packages/react-auth/src/auth-context.tsx @@ -56,6 +56,7 @@ export function createAuthProvider(config: Au linkProviderEndpoint = '/auth/providers/link', mfaVerifyEndpoint = '/auth/mfa/verify', onMfaRequired, + productId: configProductId, } = config; const USER_KEY = `${storagePrefix}_auth_user`; @@ -247,9 +248,11 @@ export function createAuthProvider(config: Au setMfaChallenge(null); setMfaMethods([]); try { + const oauthBody: Record = { idToken }; + if (configProductId) oauthBody.productId = configProductId; const { data, error: fetchError } = await api.safeFetch( `${oauthEndpoint}/${provider}`, - { method: 'POST', body: JSON.stringify({ idToken }) } + { method: 'POST', body: JSON.stringify(oauthBody) } ); if (data && !fetchError) { @@ -290,8 +293,10 @@ export function createAuthProvider(config: Au const refreshProviders = useCallback(async () => { try { - const data = await api.fetch(providersEndpoint, { method: 'GET' }); - setProviders(data); + const data = await api.fetch<{ providers: AuthProviderInfo[] }>(providersEndpoint, { + method: 'GET', + }); + setProviders(data.providers ?? []); } catch { // non-fatal — providers list is supplementary } diff --git a/packages/react-auth/src/types.ts b/packages/react-auth/src/types.ts index 13ebd6cd..029267d2 100644 --- a/packages/react-auth/src/types.ts +++ b/packages/react-auth/src/types.ts @@ -54,6 +54,8 @@ export interface LoginResult { export interface AuthConfig { /** Base URL for auth API calls. Default: '/api'. */ baseUrl?: string; + /** Product identifier sent with OAuth requests. */ + productId?: string; storagePrefix: string; loginEndpoint: string; registerEndpoint?: string; diff --git a/packages/swift-platform-sdk/Sources/BLAuthClient.swift b/packages/swift-platform-sdk/Sources/BLAuthClient.swift index 8314c2af..7d1be559 100644 --- a/packages/swift-platform-sdk/Sources/BLAuthClient.swift +++ b/packages/swift-platform-sdk/Sources/BLAuthClient.swift @@ -118,6 +118,51 @@ public struct BLGeo: Codable, Sendable { public let city: String } +// MARK: - Phase 5C–5E Types + +/// Decrypted TOTP secret for local code generation in the auth app. +public struct BLTotpSecret: Codable, Sendable { + public let secret: String + public let issuer: String + public let accountName: String + public let digits: Int + public let period: Int + public let algorithm: String +} + +/// Pending push MFA approval. +public struct BLPushApproval: Codable, Sendable { + public let id: String + public let requestProductId: String + public let requestPlatform: String + public let requestIp: String + public let requestGeo: BLGeo? + public let createdAt: String + public let expiresAt: String +} + +/// Push approval response after approve/deny. +public struct BLPushApprovalResponse: Codable, Sendable { + public let id: String + public let status: String + public let respondedAt: String? +} + +/// QR challenge for desktop/TV login. +public struct BLQrChallenge: Codable, Sendable { + public let id: String + public let challengeToken: String + public let expiresAt: String +} + +/// QR challenge poll status. +public struct BLQrStatus: Codable, Sendable { + public let status: String + public let accessToken: String? + public let refreshToken: String? + public let user: BLAuthUser? +} + // MARK: - Auth Errors /// Auth-specific errors for SmartAuth flows. @@ -503,6 +548,34 @@ public final class BLAuthClient { ) } + // MARK: - TOTP Secret Retrieval (Phase 5C) + + /// Get the decrypted TOTP secret for local code generation (auth app). + public func getTotpSecret() async throws -> BLTotpSecret { + return try await client.request(path: "/api/auth/mfa/totp/secret", responseType: BLTotpSecret.self) + } + + // MARK: - Push Approvals (Phase 5D) + + /// List pending push MFA approvals for the current user. + public func getPendingApprovals() async throws -> [BLPushApproval] { + return try await client.request(path: "/api/auth/mfa/push/pending", responseType: [BLPushApproval].self) + } + + /// Respond to a push MFA approval (approve or deny). + public func respondToApproval(approvalId: String, action: String) async throws -> BLPushApprovalResponse { + let body = ["action": action] + return try await client.request(path: "/api/auth/mfa/push/\(approvalId)/respond", method: "POST", body: body, responseType: BLPushApprovalResponse.self) + } + + // MARK: - QR Auth (Phase 5E) + + /// Confirm a QR login challenge from the auth app. + public func confirmQrLogin(challengeToken: String) async throws { + let body = ["challengeToken": challengeToken] + _ = try await client.rawRequest(path: "/api/auth/qr/confirm", method: "POST", body: body) + } + /// Restore session from stored tokens. Call on app launch. public func restoreSession() async { guard isAuthenticated else { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe5c25b9..8bde2803 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,7 +188,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) husky: specifier: ^9.0.0 version: 9.1.7 @@ -282,7 +282,7 @@ importers: version: 9.39.2(jiti@2.6.1) eslint-config-next: specifier: 16.1.6 - version: 16.1.6(@typescript-eslint/parser@8.55.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) + version: 16.1.6(@typescript-eslint/parser@8.56.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) husky: specifier: ^9.0.0 version: 9.1.7 @@ -321,6 +321,27 @@ importers: packages/auth-client: {} + packages/auth-ui: + devDependencies: + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.14) + happy-dom: + specifier: ^18.0.1 + version: 18.0.1 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + packages/blob: dependencies: '@bytelyst/storage': @@ -473,6 +494,15 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/llm-router: + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.11)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@1.8.0))(lightningcss@1.31.1)(msw@2.12.10(@types/node@22.19.11)(typescript@5.9.3))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/logger: {} packages/monitoring: {} diff --git a/services/platform-service/src/modules/auth/mfa/routes.ts b/services/platform-service/src/modules/auth/mfa/routes.ts index 3eca9c93..f30acd35 100644 --- a/services/platform-service/src/modules/auth/mfa/routes.ts +++ b/services/platform-service/src/modules/auth/mfa/routes.ts @@ -7,6 +7,7 @@ * POST /auth/mfa/disable — disable MFA (requires valid code) * POST /auth/mfa/recovery/regenerate — regenerate recovery codes * GET /auth/mfa/status — check MFA status for current user + * GET /auth/mfa/totp/secret — retrieve decrypted TOTP secret (for auth app) * * Admin MFA policies (Phase 2C): * GET /auth/mfa/policies/:productId — get MFA policy @@ -241,6 +242,32 @@ export async function mfaRoutes(app: FastifyInstance) { }; }); + // ── TOTP Secret Retrieval (for auth app) ───────────────── + + app.get('/auth/mfa/totp/secret', async req => { + const payload = req.jwtPayload; + if (!payload?.sub) throw new UnauthorizedError('Authentication required'); + + const mfaDoc = await mfaRepo.getByUserId(payload.sub); + if (!mfaDoc || !mfaDoc.verified) { + throw new BadRequestError('MFA is not enabled'); + } + + const secret = mfaRepo.decryptSecret(mfaDoc.encryptedSecret, mfaDoc.iv, mfaDoc.authTag); + const user = await userRepo.getById(payload.sub); + const issuer = 'ByteLyst'; + const accountName = user?.email ?? payload.sub; + + return { + secret, + issuer, + accountName, + digits: 6, + period: 30, + algorithm: 'SHA1', + }; + }); + // ── Admin MFA Policies (Phase 2C) ──────────────────────── app.get('/auth/mfa/policies/:productId', async req => { diff --git a/services/platform-service/src/modules/auth/push-approvals/repository.ts b/services/platform-service/src/modules/auth/push-approvals/repository.ts new file mode 100644 index 00000000..f20e4286 --- /dev/null +++ b/services/platform-service/src/modules/auth/push-approvals/repository.ts @@ -0,0 +1,75 @@ +/** + * Push approval repository — in-memory store with TTL expiry. + * Push approvals are ephemeral (5-minute TTL) so no Cosmos container needed. + */ + +import type { PushApprovalDoc } from './types.js'; +import { randomUUID } from 'node:crypto'; + +const store = new Map(); + +const EXPIRY_MS = 5 * 60 * 1000; // 5 minutes + +/** Expire old entries on access. */ +function sweep(): void { + const now = Date.now(); + for (const [, doc] of store) { + if (new Date(doc.expiresAt).getTime() <= now && doc.status === 'pending') { + doc.status = 'expired'; + } + } +} + +export function create( + params: Omit +): PushApprovalDoc { + sweep(); + const now = new Date(); + const doc: PushApprovalDoc = { + id: `pushappr_${randomUUID().slice(0, 8)}`, + ...params, + status: 'pending', + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + EXPIRY_MS).toISOString(), + }; + store.set(doc.id, doc); + return doc; +} + +export function getById(id: string): PushApprovalDoc | undefined { + sweep(); + return store.get(id); +} + +export function getPendingByUserId(userId: string): PushApprovalDoc[] { + sweep(); + const results: PushApprovalDoc[] = []; + for (const doc of store.values()) { + if (doc.userId === userId && doc.status === 'pending') { + results.push(doc); + } + } + return results.sort((a, b) => b.createdAt.localeCompare(a.createdAt)); +} + +export function respond(id: string, action: 'approved' | 'denied'): PushApprovalDoc | undefined { + sweep(); + const doc = store.get(id); + if (!doc || doc.status !== 'pending') return undefined; + doc.status = action; + doc.respondedAt = new Date().toISOString(); + return doc; +} + +export function getByChallenge(challengeToken: string): PushApprovalDoc | undefined { + sweep(); + for (const doc of store.values()) { + if (doc.challengeToken === challengeToken) return doc; + } + return undefined; +} + +/** Clear all — for testing. */ +export function _clear(): void { + store.clear(); +} diff --git a/services/platform-service/src/modules/auth/push-approvals/routes.ts b/services/platform-service/src/modules/auth/push-approvals/routes.ts new file mode 100644 index 00000000..b58d4749 --- /dev/null +++ b/services/platform-service/src/modules/auth/push-approvals/routes.ts @@ -0,0 +1,106 @@ +/** + * Push MFA approval endpoints for SmartAuth Phase 5D. + * + * POST /auth/mfa/push/create — create a push approval request (internal/service) + * GET /auth/mfa/push/pending — list pending approvals for current user + * POST /auth/mfa/push/:id/respond — approve or deny a push approval + * GET /auth/mfa/push/:id/status — poll approval status (for requesting client) + */ + +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, UnauthorizedError, NotFoundError } from '../../../lib/errors.js'; +import * as repo from './repository.js'; +import { PushApprovalRespondSchema, PushApprovalCreateSchema } from './types.js'; + +export async function pushApprovalRoutes(app: FastifyInstance) { + // ── Create push approval (called by login flow when push MFA is triggered) ── + + app.post('/auth/mfa/push/create', async req => { + const payload = req.jwtPayload; + // Allow service-to-service or admin calls + if (!payload?.sub) throw new UnauthorizedError('Authentication required'); + if (!payload.role || !['super_admin', 'admin', 'service'].includes(payload.role)) { + throw new UnauthorizedError('Service or admin access required'); + } + + const parsed = PushApprovalCreateSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const approval = repo.create({ + productId: 'smartauth', + userId: parsed.data.userId, + requestProductId: parsed.data.requestProductId, + requestPlatform: parsed.data.requestPlatform, + requestIp: parsed.data.requestIp, + requestGeo: parsed.data.requestGeo, + challengeToken: parsed.data.challengeToken, + }); + + req.log.info( + { approvalId: approval.id, userId: parsed.data.userId }, + '[auth] Push approval created' + ); + return { id: approval.id, expiresAt: approval.expiresAt }; + }); + + // ── List pending approvals for current user ───────────────── + + app.get('/auth/mfa/push/pending', async req => { + const payload = req.jwtPayload; + if (!payload?.sub) throw new UnauthorizedError('Authentication required'); + + const pending = repo.getPendingByUserId(payload.sub); + return pending.map(a => ({ + id: a.id, + requestProductId: a.requestProductId, + requestPlatform: a.requestPlatform, + requestIp: a.requestIp, + requestGeo: a.requestGeo, + createdAt: a.createdAt, + expiresAt: a.expiresAt, + })); + }); + + // ── Respond to a push approval ───────────────────────────── + + app.post('/auth/mfa/push/:id/respond', async req => { + const payload = req.jwtPayload; + if (!payload?.sub) throw new UnauthorizedError('Authentication required'); + + const { id } = req.params as { id: string }; + const parsed = PushApprovalRespondSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const approval = repo.getById(id); + if (!approval) throw new NotFoundError('Approval not found'); + if (approval.userId !== payload.sub) throw new UnauthorizedError('Not your approval'); + if (approval.status !== 'pending') { + throw new BadRequestError(`Approval already ${approval.status}`); + } + + const action = parsed.data.action === 'approve' ? ('approved' as const) : ('denied' as const); + const updated = repo.respond(id, action); + if (!updated) throw new BadRequestError('Failed to respond to approval'); + + req.log.info({ approvalId: id, action, userId: payload.sub }, '[auth] Push approval responded'); + return { id: updated.id, status: updated.status, respondedAt: updated.respondedAt }; + }); + + // ── Poll approval status (for the requesting login client) ── + + app.get('/auth/mfa/push/:id/status', async req => { + const { id } = req.params as { id: string }; + const approval = repo.getById(id); + if (!approval) throw new NotFoundError('Approval not found'); + + return { + id: approval.id, + status: approval.status, + respondedAt: approval.respondedAt ?? null, + }; + }); +} diff --git a/services/platform-service/src/modules/auth/push-approvals/types.ts b/services/platform-service/src/modules/auth/push-approvals/types.ts new file mode 100644 index 00000000..3ad5be4e --- /dev/null +++ b/services/platform-service/src/modules/auth/push-approvals/types.ts @@ -0,0 +1,44 @@ +/** + * Push MFA approval types for SmartAuth Phase 5D. + * Push approvals allow a logged-in auth app to approve/deny login attempts. + */ + +import { z } from 'zod'; + +export interface PushApprovalDoc { + id: string; // "pushappr_{random}" + productId: string; + userId: string; + /** Which product triggered the login */ + requestProductId: string; + /** Client platform that requested login */ + requestPlatform: string; + /** IP of the login request */ + requestIp: string; + /** Geo location (best effort) */ + requestGeo?: { country: string; city: string }; + status: 'pending' | 'approved' | 'denied' | 'expired'; + /** Challenge token for the pending login */ + challengeToken: string; + createdAt: string; + expiresAt: string; + respondedAt?: string; +} + +export const PushApprovalRespondSchema = z.object({ + action: z.enum(['approve', 'deny']), +}); + +export const PushApprovalCreateSchema = z.object({ + userId: z.string().min(1), + requestProductId: z.string().min(1), + requestPlatform: z.string().min(1), + requestIp: z.string().default('unknown'), + requestGeo: z + .object({ + country: z.string(), + city: z.string(), + }) + .optional(), + challengeToken: z.string().min(1), +}); diff --git a/services/platform-service/src/modules/auth/qr-auth/repository.ts b/services/platform-service/src/modules/auth/qr-auth/repository.ts new file mode 100644 index 00000000..0d141671 --- /dev/null +++ b/services/platform-service/src/modules/auth/qr-auth/repository.ts @@ -0,0 +1,64 @@ +/** + * QR auth repository — in-memory store with TTL expiry. + * QR challenges are ephemeral (3-minute TTL) so no Cosmos container needed. + */ + +import type { QrChallengeDoc } from './types.js'; +import { randomUUID, randomBytes } from 'node:crypto'; + +const store = new Map(); + +const EXPIRY_MS = 3 * 60 * 1000; // 3 minutes + +function sweep(): void { + const now = Date.now(); + for (const [, doc] of store) { + if (new Date(doc.expiresAt).getTime() <= now && doc.status === 'pending') { + doc.status = 'expired'; + } + } +} + +export function create(productId: string, platform: string): QrChallengeDoc { + sweep(); + const now = new Date(); + const doc: QrChallengeDoc = { + id: `qr_${randomUUID().slice(0, 8)}`, + challengeToken: randomBytes(32).toString('base64url'), + productId, + platform, + status: 'pending', + createdAt: now.toISOString(), + expiresAt: new Date(now.getTime() + EXPIRY_MS).toISOString(), + }; + store.set(doc.id, doc); + return doc; +} + +export function getById(id: string): QrChallengeDoc | undefined { + sweep(); + return store.get(id); +} + +export function getByChallenge(challengeToken: string): QrChallengeDoc | undefined { + sweep(); + for (const doc of store.values()) { + if (doc.challengeToken === challengeToken) return doc; + } + return undefined; +} + +export function confirm(challengeToken: string, userId: string): QrChallengeDoc | undefined { + sweep(); + const doc = getByChallenge(challengeToken); + if (!doc || doc.status !== 'pending') return undefined; + doc.status = 'confirmed'; + doc.userId = userId; + doc.confirmedAt = new Date().toISOString(); + return doc; +} + +/** Clear all — for testing. */ +export function _clear(): void { + store.clear(); +} diff --git a/services/platform-service/src/modules/auth/qr-auth/routes.ts b/services/platform-service/src/modules/auth/qr-auth/routes.ts new file mode 100644 index 00000000..569ad158 --- /dev/null +++ b/services/platform-service/src/modules/auth/qr-auth/routes.ts @@ -0,0 +1,100 @@ +/** + * QR auth endpoints for SmartAuth Phase 5E. + * + * POST /auth/qr/create — create a QR challenge (desktop/TV client) + * POST /auth/qr/confirm — confirm QR login from auth app (mobile, authenticated) + * GET /auth/qr/:id/status — poll QR challenge status (desktop/TV client) + */ + +import type { FastifyInstance } from 'fastify'; +import { BadRequestError, UnauthorizedError, NotFoundError } from '../../../lib/errors.js'; +import * as repo from './repository.js'; +import * as jwt from '../jwt.js'; +import * as userRepo from '../repository.js'; +import { QrChallengeCreateSchema, QrConfirmSchema } from './types.js'; + +export async function qrAuthRoutes(app: FastifyInstance) { + // ── Create QR challenge (unauthenticated — desktop shows QR) ── + + app.post('/auth/qr/create', async req => { + const parsed = QrChallengeCreateSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const challenge = repo.create(parsed.data.productId, parsed.data.platform); + + req.log.info({ challengeId: challenge.id }, '[auth] QR challenge created'); + return { + id: challenge.id, + challengeToken: challenge.challengeToken, + expiresAt: challenge.expiresAt, + }; + }); + + // ── Confirm QR login (auth app — authenticated user scans QR) ── + + app.post('/auth/qr/confirm', async req => { + const payload = req.jwtPayload; + if (!payload?.sub) throw new UnauthorizedError('Authentication required'); + + const parsed = QrConfirmSchema.safeParse(req.body); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + const challenge = repo.confirm(parsed.data.challengeToken, payload.sub); + if (!challenge) { + throw new BadRequestError('Invalid, expired, or already confirmed QR challenge'); + } + + req.log.info({ challengeId: challenge.id, userId: payload.sub }, '[auth] QR login confirmed'); + return { message: 'Login authorized', challengeId: challenge.id }; + }); + + // ── Poll QR challenge status (desktop polls until confirmed) ── + + app.get('/auth/qr/:id/status', async req => { + const { id } = req.params as { id: string }; + const challenge = repo.getById(id); + if (!challenge) throw new NotFoundError('QR challenge not found'); + + if (challenge.status === 'confirmed' && challenge.userId) { + // Issue tokens for the confirmed user + const user = await userRepo.getById(challenge.userId); + if (!user) throw new NotFoundError('User not found'); + + const accessToken = await jwt.createAccessToken({ + sub: user.id, + email: user.email, + role: user.role as 'user' | 'admin' | 'super_admin', + productId: challenge.productId, + plan: (user.plan ?? 'free') as 'free' | 'pro' | 'enterprise', + }); + const refreshToken = await jwt.createRefreshToken({ + sub: user.id, + productId: challenge.productId, + }); + + return { + status: 'confirmed', + accessToken, + refreshToken, + user: { + id: user.id, + email: user.email, + displayName: user.displayName, + plan: user.plan, + role: user.role, + }, + }; + } + + return { + status: challenge.status, + accessToken: null, + refreshToken: null, + user: null, + }; + }); +} diff --git a/services/platform-service/src/modules/auth/qr-auth/types.ts b/services/platform-service/src/modules/auth/qr-auth/types.ts new file mode 100644 index 00000000..73f9281b --- /dev/null +++ b/services/platform-service/src/modules/auth/qr-auth/types.ts @@ -0,0 +1,32 @@ +/** + * QR auth types for SmartAuth Phase 5E. + * QR auth allows a mobile auth app to scan a QR code on desktop/TV + * and authorize the login from the phone. + */ + +import { z } from 'zod'; + +export interface QrChallengeDoc { + id: string; // "qr_{random}" + /** The challenge token embedded in the QR code */ + challengeToken: string; + /** Which product is requesting login */ + productId: string; + /** Client platform that displayed the QR (e.g. "web", "desktop") */ + platform: string; + status: 'pending' | 'confirmed' | 'expired'; + /** Set when the auth app confirms */ + userId?: string; + createdAt: string; + expiresAt: string; + confirmedAt?: string; +} + +export const QrChallengeCreateSchema = z.object({ + productId: z.string().min(1), + platform: z.string().min(1).default('web'), +}); + +export const QrConfirmSchema = z.object({ + challengeToken: z.string().min(1), +}); diff --git a/services/platform-service/src/server.ts b/services/platform-service/src/server.ts index 85752f11..ee9e5651 100644 --- a/services/platform-service/src/server.ts +++ b/services/platform-service/src/server.ts @@ -29,6 +29,8 @@ import { mfaRoutes } from './modules/auth/mfa/routes.js'; import { passkeyRoutes } from './modules/auth/passkeys/routes.js'; import { deviceRoutes } from './modules/auth/devices/routes.js'; import { loginEventRoutes } from './modules/auth/login-events/routes.js'; +import { pushApprovalRoutes } from './modules/auth/push-approvals/routes.js'; +import { qrAuthRoutes } from './modules/auth/qr-auth/routes.js'; import { auditRoutes } from './modules/audit/routes.js'; import { notificationRoutes } from './modules/notifications/routes.js'; import { flagRoutes } from './modules/flags/routes.js'; @@ -122,6 +124,8 @@ await app.register(mfaRoutes, { prefix: '/api' }); await app.register(passkeyRoutes, { prefix: '/api' }); await app.register(deviceRoutes, { prefix: '/api' }); await app.register(loginEventRoutes, { prefix: '/api' }); +await app.register(pushApprovalRoutes, { prefix: '/api' }); +await app.register(qrAuthRoutes, { prefix: '/api' }); await app.register(auditRoutes, { prefix: '/api' }); await app.register(notificationRoutes, { prefix: '/api' }); await app.register(flagRoutes, { prefix: '/api' });