From 91c48a7bc765671d9a71b8893ae8d2c0ad26c4f6 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Sun, 1 Mar 2026 18:15:57 -0800 Subject: [PATCH] feat(sdk): add kotlin-platform-sdk (13 components) + 4 new TS client packages (32 tests) --- packages/feature-flag-client/package.json | 21 ++ .../feature-flag-client/src/client.test.ts | 165 ++++++++++ packages/feature-flag-client/src/client.ts | 110 +++++++ packages/feature-flag-client/src/index.ts | 2 + packages/feature-flag-client/src/types.ts | 44 +++ packages/feature-flag-client/tsconfig.json | 8 + packages/kill-switch-client/package.json | 21 ++ packages/kill-switch-client/src/index.test.ts | 102 ++++++ packages/kill-switch-client/src/index.ts | 68 ++++ packages/kill-switch-client/tsconfig.json | 8 + packages/kotlin-platform-sdk/build.gradle.kts | 57 ++++ .../kotlin-platform-sdk/consumer-rules.pro | 3 + .../kotlin-platform-sdk/gradle.properties | 1 + .../kotlin-platform-sdk/settings.gradle.kts | 1 + .../src/main/AndroidManifest.xml | 5 + .../com/bytelyst/platform/BLAuditLogger.kt | 111 +++++++ .../com/bytelyst/platform/BLAuthClient.kt | 304 ++++++++++++++++++ .../com/bytelyst/platform/BLBiometricAuth.kt | 83 +++++ .../com/bytelyst/platform/BLBlobClient.kt | 93 ++++++ .../com/bytelyst/platform/BLCrashReporter.kt | 97 ++++++ .../bytelyst/platform/BLFeatureFlagClient.kt | 90 ++++++ .../bytelyst/platform/BLKillSwitchClient.kt | 41 +++ .../com/bytelyst/platform/BLLicenseClient.kt | 82 +++++ .../com/bytelyst/platform/BLPlatformClient.kt | 99 ++++++ .../com/bytelyst/platform/BLPlatformConfig.kt | 27 ++ .../com/bytelyst/platform/BLSecureStore.kt | 51 +++ .../com/bytelyst/platform/BLSyncEngine.kt | 111 +++++++ .../bytelyst/platform/BLTelemetryClient.kt | 191 +++++++++++ packages/offline-queue/package.json | 21 ++ packages/offline-queue/src/index.test.ts | 143 ++++++++ packages/offline-queue/src/index.ts | 166 ++++++++++ packages/offline-queue/tsconfig.json | 8 + packages/platform-client/package.json | 21 ++ packages/platform-client/src/index.test.ts | 162 ++++++++++ packages/platform-client/src/index.ts | 156 +++++++++ packages/platform-client/tsconfig.json | 8 + pnpm-lock.yaml | 8 + 37 files changed, 2689 insertions(+) create mode 100644 packages/feature-flag-client/package.json create mode 100644 packages/feature-flag-client/src/client.test.ts create mode 100644 packages/feature-flag-client/src/client.ts create mode 100644 packages/feature-flag-client/src/index.ts create mode 100644 packages/feature-flag-client/src/types.ts create mode 100644 packages/feature-flag-client/tsconfig.json create mode 100644 packages/kill-switch-client/package.json create mode 100644 packages/kill-switch-client/src/index.test.ts create mode 100644 packages/kill-switch-client/src/index.ts create mode 100644 packages/kill-switch-client/tsconfig.json create mode 100644 packages/kotlin-platform-sdk/build.gradle.kts create mode 100644 packages/kotlin-platform-sdk/consumer-rules.pro create mode 100644 packages/kotlin-platform-sdk/gradle.properties create mode 100644 packages/kotlin-platform-sdk/settings.gradle.kts create mode 100644 packages/kotlin-platform-sdk/src/main/AndroidManifest.xml create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt create mode 100644 packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt create mode 100644 packages/offline-queue/package.json create mode 100644 packages/offline-queue/src/index.test.ts create mode 100644 packages/offline-queue/src/index.ts create mode 100644 packages/offline-queue/tsconfig.json create mode 100644 packages/platform-client/package.json create mode 100644 packages/platform-client/src/index.test.ts create mode 100644 packages/platform-client/src/index.ts create mode 100644 packages/platform-client/tsconfig.json diff --git a/packages/feature-flag-client/package.json b/packages/feature-flag-client/package.json new file mode 100644 index 00000000..f56eb138 --- /dev/null +++ b/packages/feature-flag-client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bytelyst/feature-flag-client", + "version": "0.1.0", + "type": "module", + "description": "Browser/React Native-safe feature flag client for platform-service", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + } +} diff --git a/packages/feature-flag-client/src/client.test.ts b/packages/feature-flag-client/src/client.test.ts new file mode 100644 index 00000000..d335680e --- /dev/null +++ b/packages/feature-flag-client/src/client.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createFeatureFlagClient } from './client.js'; + +describe('createFeatureFlagClient', () => { + const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + platform: 'web', + }; + + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it('should create a client with isEnabled returning false before init', () => { + const client = createFeatureFlagClient(baseConfig); + expect(client.isEnabled('any_flag')).toBe(false); + }); + + it('should fetch flags on init', async () => { + const mockFlags = { premium: true, beta_feature: false }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: mockFlags }), + }) + ); + + const client = createFeatureFlagClient(baseConfig); + await client.init(); + + expect(client.isEnabled('premium')).toBe(true); + expect(client.isEnabled('beta_feature')).toBe(false); + expect(client.isEnabled('nonexistent')).toBe(false); + client.stop(); + }); + + it('should return all flags via getAllFlags', async () => { + const mockFlags = { a: true, b: false }; + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: mockFlags }), + }) + ); + + const client = createFeatureFlagClient(baseConfig); + await client.init(); + + expect(client.getAllFlags()).toEqual({ a: true, b: false }); + client.stop(); + }); + + it('should send correct headers', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: {} }), + }); + vi.stubGlobal('fetch', fetchMock); + + const client = createFeatureFlagClient(baseConfig); + await client.init({ userId: 'user-123' }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/flags/poll?'), + expect.objectContaining({ + headers: { 'x-product-id': 'testapp' }, + }) + ); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('platform=web'); + expect(url).toContain('userId=user-123'); + client.stop(); + }); + + it('should keep existing flags on network error', async () => { + let callCount = 0; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ flags: { initial: true } }), + }); + } + return Promise.reject(new Error('network')); + }) + ); + + const client = createFeatureFlagClient(baseConfig); + await client.init(); + expect(client.isEnabled('initial')).toBe(true); + + await client.refresh(); + expect(client.isEnabled('initial')).toBe(true); + client.stop(); + }); + + it('should persist to storage when provided', async () => { + const store: Record = {}; + const storage = { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + }; + + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: { cached: true } }), + }) + ); + + const client = createFeatureFlagClient({ ...baseConfig, storage }); + await client.init(); + + expect(store['testapp-feature-flags']).toBeDefined(); + expect(JSON.parse(store['testapp-feature-flags'])).toEqual({ cached: true }); + client.stop(); + }); + + it('should restore flags from storage on creation', () => { + const store: Record = { + 'testapp-feature-flags': JSON.stringify({ restored: true }), + }; + const storage = { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + }; + + const client = createFeatureFlagClient({ ...baseConfig, storage }); + expect(client.isEnabled('restored')).toBe(true); + }); + + it('should stop polling on stop()', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ flags: {} }), + }) + ); + + const client = createFeatureFlagClient({ ...baseConfig, pollIntervalMs: 1000 }); + await client.init(); + client.stop(); + + expect(client.isEnabled('anything')).toBe(false); + expect(client.getAllFlags()).toEqual({}); + }); +}); diff --git a/packages/feature-flag-client/src/client.ts b/packages/feature-flag-client/src/client.ts new file mode 100644 index 00000000..7de5b93c --- /dev/null +++ b/packages/feature-flag-client/src/client.ts @@ -0,0 +1,110 @@ +/** + * Browser/React Native-safe feature flag client for platform-service. + * + * Polls GET /api/flags/poll on a configurable interval and caches results. + * No Node.js dependencies — uses globalThis.fetch. + * + * @example + * ```ts + * import { createFeatureFlagClient } from '@bytelyst/feature-flag-client'; + * + * const flags = createFeatureFlagClient({ + * baseUrl: 'http://localhost:4003/api', + * productId: 'nomgap', + * platform: 'mobile', + * }); + * + * await flags.init({ userId: 'user-123' }); + * if (flags.isEnabled('premium_body_viz')) { ... } + * ``` + */ + +import type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js'; + +export function createFeatureFlagClient(config: FeatureFlagClientConfig): FeatureFlagClient { + const { + baseUrl, + productId, + platform, + pollIntervalMs = 5 * 60 * 1000, + storage, + storagePrefix, + } = config; + + const prefix = storagePrefix ?? productId; + const STORAGE_KEY = `${prefix}-feature-flags`; + + let flags: Record = {}; + let initialized = false; + let intervalId: ReturnType | null = null; + let userId: string | undefined; + + // Restore from storage on creation + if (storage) { + try { + const cached = storage.getItem(STORAGE_KEY); + if (cached) flags = JSON.parse(cached); + } catch { + // Ignore parse errors + } + } + + async function fetchFlags(): Promise { + try { + const parts = [`platform=${encodeURIComponent(platform)}`]; + if (userId) parts.push(`userId=${encodeURIComponent(userId)}`); + + const res = await globalThis.fetch(`${baseUrl}/flags/poll?${parts.join('&')}`, { + headers: { 'x-product-id': productId }, + }); + + if (!res.ok) return; + + const data = (await res.json()) as { flags?: Record }; + flags = data.flags ?? {}; + + // Persist to storage + if (storage) { + try { + storage.setItem(STORAGE_KEY, JSON.stringify(flags)); + } catch { + // Storage write failure — non-fatal + } + } + } catch { + // Keep existing flags on network error + } + } + + async function init(params?: { userId?: string }): Promise { + if (initialized) return; + initialized = true; + userId = params?.userId; + await fetchFlags(); + intervalId = setInterval(() => { + void fetchFlags(); + }, pollIntervalMs); + } + + function isEnabled(key: string): boolean { + return flags[key] === true; + } + + function getAllFlags(): Readonly> { + return flags; + } + + async function refresh(): Promise { + await fetchFlags(); + } + + function stop(): void { + if (intervalId) clearInterval(intervalId); + intervalId = null; + flags = {}; + initialized = false; + userId = undefined; + } + + return { init, isEnabled, getAllFlags, refresh, stop }; +} diff --git a/packages/feature-flag-client/src/index.ts b/packages/feature-flag-client/src/index.ts new file mode 100644 index 00000000..e3a7aaf1 --- /dev/null +++ b/packages/feature-flag-client/src/index.ts @@ -0,0 +1,2 @@ +export { createFeatureFlagClient } from './client.js'; +export type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js'; diff --git a/packages/feature-flag-client/src/types.ts b/packages/feature-flag-client/src/types.ts new file mode 100644 index 00000000..e0ca4e1c --- /dev/null +++ b/packages/feature-flag-client/src/types.ts @@ -0,0 +1,44 @@ +/** + * Types for @bytelyst/feature-flag-client. + * Browser/React Native-safe — no Node.js dependencies. + */ + +export interface FeatureFlagClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header. */ + productId: string; + + /** Platform string for the poll query (e.g. "mobile", "web"). */ + platform: string; + + /** Poll interval in milliseconds. Default: 5 minutes. */ + pollIntervalMs?: number; + + /** Optional persistent storage adapter for flag cache. */ + storage?: { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + }; + + /** Optional storage key prefix. Default: productId. */ + storagePrefix?: string; +} + +export interface FeatureFlagClient { + /** Initialize the client: fetch flags immediately and start polling. */ + init(params?: { userId?: string }): Promise; + + /** Check if a feature flag is enabled. Returns false if not found. */ + isEnabled(key: string): boolean; + + /** Get all currently cached flags. */ + getAllFlags(): Readonly>; + + /** Force a refresh of feature flags. */ + refresh(): Promise; + + /** Stop polling and reset state. */ + stop(): void; +} diff --git a/packages/feature-flag-client/tsconfig.json b/packages/feature-flag-client/tsconfig.json new file mode 100644 index 00000000..5a24989c --- /dev/null +++ b/packages/feature-flag-client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/kill-switch-client/package.json b/packages/kill-switch-client/package.json new file mode 100644 index 00000000..d240cc8e --- /dev/null +++ b/packages/kill-switch-client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bytelyst/kill-switch-client", + "version": "0.1.0", + "type": "module", + "description": "Browser/React Native-safe kill switch client for platform-service", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + } +} diff --git a/packages/kill-switch-client/src/index.test.ts b/packages/kill-switch-client/src/index.test.ts new file mode 100644 index 00000000..2d349d88 --- /dev/null +++ b/packages/kill-switch-client/src/index.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createKillSwitchClient } from './index.js'; + +describe('createKillSwitchClient', () => { + const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return disabled=false when app is not disabled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ disabled: false, message: null }), + }) + ); + + const ks = createKillSwitchClient(baseConfig); + const result = await ks.check(); + + expect(result.disabled).toBe(false); + expect(result.message).toBeNull(); + }); + + it('should return disabled=true with message when app is disabled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ disabled: true, message: 'Maintenance in progress' }), + }) + ); + + const ks = createKillSwitchClient(baseConfig); + const result = await ks.check(); + + expect(result.disabled).toBe(true); + expect(result.message).toBe('Maintenance in progress'); + }); + + it('should fail-open on network error', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network'))); + + const ks = createKillSwitchClient(baseConfig); + const result = await ks.check(); + + expect(result.disabled).toBe(false); + expect(result.message).toBeNull(); + }); + + it('should fail-open on non-OK response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + }) + ); + + const ks = createKillSwitchClient(baseConfig); + const result = await ks.check(); + + expect(result.disabled).toBe(false); + }); + + it('should send correct product-id header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ disabled: false }), + }); + vi.stubGlobal('fetch', fetchMock); + + const ks = createKillSwitchClient(baseConfig); + await ks.check(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/flags/kill-switch'), + expect.objectContaining({ + headers: { 'x-product-id': 'testapp' }, + }) + ); + }); + + it('should include platform in query string', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ disabled: false }), + }); + vi.stubGlobal('fetch', fetchMock); + + const ks = createKillSwitchClient({ ...baseConfig, platform: 'ios' }); + await ks.check(); + + const url = fetchMock.mock.calls[0][0] as string; + expect(url).toContain('platform=ios'); + }); +}); diff --git a/packages/kill-switch-client/src/index.ts b/packages/kill-switch-client/src/index.ts new file mode 100644 index 00000000..c8ebd2f3 --- /dev/null +++ b/packages/kill-switch-client/src/index.ts @@ -0,0 +1,68 @@ +/** + * Browser/React Native-safe kill switch client for platform-service. + * + * Checks GET /api/flags/kill-switch to determine if the app is disabled. + * Fail-open: returns { disabled: false } on any network error. + * + * @example + * ```ts + * import { createKillSwitchClient } from '@bytelyst/kill-switch-client'; + * + * const ks = createKillSwitchClient({ + * baseUrl: 'http://localhost:4003/api', + * productId: 'nomgap', + * }); + * + * const result = await ks.check(); + * if (result.disabled) showBlockScreen(result.message); + * ``` + */ + +export interface KillSwitchClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header. */ + productId: string; + + /** Platform string for the query (e.g. "mobile", "web"). Default: "mobile". */ + platform?: string; +} + +export interface KillSwitchResult { + disabled: boolean; + message: string | null; +} + +export interface KillSwitchClient { + /** Check if the app is disabled. Fail-open on any error. */ + check(): Promise; +} + +export function createKillSwitchClient(config: KillSwitchClientConfig): KillSwitchClient { + const { baseUrl, productId, platform = 'mobile' } = config; + + async function check(): Promise { + try { + const res = await globalThis.fetch( + `${baseUrl}/flags/kill-switch?platform=${encodeURIComponent(platform)}`, + { + headers: { 'x-product-id': productId }, + } + ); + + if (!res.ok) return { disabled: false, message: null }; + + const data = (await res.json()) as KillSwitchResult; + return { + disabled: data.disabled ?? false, + message: data.message ?? null, + }; + } catch { + // Fail-open: network errors should NOT block the user + return { disabled: false, message: null }; + } + } + + return { check }; +} diff --git a/packages/kill-switch-client/tsconfig.json b/packages/kill-switch-client/tsconfig.json new file mode 100644 index 00000000..5a24989c --- /dev/null +++ b/packages/kill-switch-client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/kotlin-platform-sdk/build.gradle.kts b/packages/kotlin-platform-sdk/build.gradle.kts new file mode 100644 index 00000000..6f727402 --- /dev/null +++ b/packages/kotlin-platform-sdk/build.gradle.kts @@ -0,0 +1,57 @@ +plugins { + id("com.android.library") version "8.7.3" + id("org.jetbrains.kotlin.android") version "2.1.0" + id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0" +} + +android { + namespace = "com.bytelyst.platform" + compileSdk = 35 + + defaultConfig { + minSdk = 26 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } +} + +group = "com.bytelyst.platform" +version = "0.1.0" + +dependencies { + // HTTP + api("com.squareup.okhttp3:okhttp:4.12.0") + + // Serialization + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") + + // Coroutines + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0") + + // Android + implementation("androidx.security:security-crypto:1.0.0") + implementation("androidx.biometric:biometric:1.1.0") + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7") + + // Testing + testImplementation("org.junit.jupiter:junit-jupiter-api:5.11.4") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.11.4") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") + testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0") + testImplementation("org.robolectric:robolectric:4.14.1") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/packages/kotlin-platform-sdk/consumer-rules.pro b/packages/kotlin-platform-sdk/consumer-rules.pro new file mode 100644 index 00000000..a7740578 --- /dev/null +++ b/packages/kotlin-platform-sdk/consumer-rules.pro @@ -0,0 +1,3 @@ +# ByteLyst Platform SDK — consumer ProGuard rules +# Keep all SDK public API classes +-keep class com.bytelyst.platform.** { *; } diff --git a/packages/kotlin-platform-sdk/gradle.properties b/packages/kotlin-platform-sdk/gradle.properties new file mode 100644 index 00000000..5bac8ac5 --- /dev/null +++ b/packages/kotlin-platform-sdk/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true diff --git a/packages/kotlin-platform-sdk/settings.gradle.kts b/packages/kotlin-platform-sdk/settings.gradle.kts new file mode 100644 index 00000000..c7b57cb7 --- /dev/null +++ b/packages/kotlin-platform-sdk/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "kotlin-platform-sdk" diff --git a/packages/kotlin-platform-sdk/src/main/AndroidManifest.xml b/packages/kotlin-platform-sdk/src/main/AndroidManifest.xml new file mode 100644 index 00000000..1f26d1e8 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt new file mode 100644 index 00000000..00f3fa28 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuditLogger.kt @@ -0,0 +1,111 @@ +package com.bytelyst.platform + +import android.content.Context +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +/** + * Local rotating JSON audit log. + * + * Writes audit entries to a JSON lines file in the app's files directory. + * Rotates when the file exceeds [maxFileSizeBytes]. Keeps [maxFiles] rotated files. + * + * Mirrors the Swift BLAuditLogger API. + */ +class BLAuditLogger( + context: Context, + private val config: BLPlatformConfig, + private val maxFileSizeBytes: Long = 1_000_000L, + private val maxFiles: Int = 5, +) { + @Serializable + data class AuditEntry( + val timestamp: String, + val productId: String, + val action: String, + val module: String, + val detail: String? = null, + val userId: String? = null, + ) + + private val json = Json { encodeDefaults = true } + private val logDir = File(context.filesDir, "audit_logs") + private val currentFile: File + get() = File(logDir, "${config.productId}_audit.jsonl") + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + init { + logDir.mkdirs() + } + + /** + * Log an audit event. + */ + fun log(action: String, module: String, detail: String? = null, userId: String? = null) { + val entry = AuditEntry( + timestamp = isoFormat.format(Date()), + productId = config.productId, + action = action, + module = module, + detail = detail, + userId = userId, + ) + + try { + rotateIfNeeded() + currentFile.appendText(json.encodeToString(entry) + "\n") + } catch (_: Exception) { + // Audit logging should never crash the app + } + } + + /** + * Read all entries from the current log file. + */ + fun readEntries(): List { + return try { + if (!currentFile.exists()) return emptyList() + currentFile.readLines() + .filter { it.isNotBlank() } + .mapNotNull { + try { json.decodeFromString(it) } catch (_: Exception) { null } + } + } catch (_: Exception) { + emptyList() + } + } + + /** + * Clear all audit log files. + */ + fun clear() { + try { + logDir.listFiles()?.forEach { it.delete() } + } catch (_: Exception) { + // Ignore + } + } + + private fun rotateIfNeeded() { + if (!currentFile.exists() || currentFile.length() < maxFileSizeBytes) return + + // Rotate: current → .1, .1 → .2, etc. + for (i in maxFiles downTo 1) { + val from = if (i == 1) currentFile else File(logDir, "${config.productId}_audit.${i - 1}.jsonl") + val to = File(logDir, "${config.productId}_audit.$i.jsonl") + if (from.exists()) { + to.delete() + from.renameTo(to) + } + } + } +} 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 new file mode 100644 index 00000000..4adaafee --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLAuthClient.kt @@ -0,0 +1,304 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Auth client for platform-service. + * + * Manages login, register, token refresh, password operations. + * Tokens are stored in [BLSecureStore] (EncryptedSharedPreferences). + * Auth state is exposed as a [StateFlow] for reactive UI binding. + * + * Mirrors the Swift BLAuthClient API. + */ +class BLAuthClient( + private val config: BLPlatformConfig, + private val secureStore: BLSecureStore, +) { + // ── Data classes ────────────────────────────────────────── + + @Serializable + data class AuthUser( + val id: String = "", + val email: String = "", + @SerialName("displayName") + val name: String = "", + val plan: String = "free", + val role: String = "user", + ) + + @Serializable + data class TokenResponse( + val accessToken: String, + val refreshToken: String, + val user: AuthUser, + ) + + @Serializable + data class RefreshResponse( + val accessToken: String, + val refreshToken: String, + ) + + @Serializable + data class MessageResponse(val message: String) + + sealed class AuthState { + data object Loading : AuthState() + data object LoggedOut : AuthState() + data class LoggedIn(val user: AuthUser) : AuthState() + data class Error(val message: String) : AuthState() + } + + // ── State ──────────────────────────────────────────────── + + private val _state = MutableStateFlow(AuthState.Loading) + val state: StateFlow = _state.asStateFlow() + + val isLoggedIn: Boolean + get() = _state.value is AuthState.LoggedIn + + val currentUser: AuthUser? + get() = (_state.value as? AuthState.LoggedIn)?.user + + // ── Storage keys (bare — applicationId provides namespace) ── + + companion object { + private const val KEY_ACCESS_TOKEN = "access_token" + private const val KEY_REFRESH_TOKEN = "refresh_token" + private const val KEY_USER_EMAIL = "user_email" + private const val KEY_USER_NAME = "user_name" + private const val KEY_USER_PLAN = "user_plan" + private const val KEY_USER_ID = "user_id" + } + + // ── Platform client ────────────────────────────────────── + + private val client = BLPlatformClient(config) { getAccessToken() } + + // ── Token management ───────────────────────────────────── + + fun getAccessToken(): String? = secureStore.read(KEY_ACCESS_TOKEN) + + fun getRefreshToken(): String? = secureStore.read(KEY_REFRESH_TOKEN) + + private fun setTokens(accessToken: String, refreshToken: String) { + secureStore.save(KEY_ACCESS_TOKEN, accessToken) + secureStore.save(KEY_REFRESH_TOKEN, refreshToken) + } + + private fun saveUser(user: AuthUser) { + secureStore.save(KEY_USER_ID, user.id) + secureStore.save(KEY_USER_EMAIL, user.email) + secureStore.save(KEY_USER_NAME, user.name) + secureStore.save(KEY_USER_PLAN, user.plan) + } + + private fun clearAll() { + secureStore.delete(KEY_ACCESS_TOKEN) + secureStore.delete(KEY_REFRESH_TOKEN) + secureStore.delete(KEY_USER_EMAIL) + secureStore.delete(KEY_USER_NAME) + secureStore.delete(KEY_USER_PLAN) + secureStore.delete(KEY_USER_ID) + } + + // ── Session check ──────────────────────────────────────── + + /** + * Check for an existing session from stored tokens. + * Call once at app startup to restore auth state. + */ + fun checkExistingSession() { + val token = secureStore.read(KEY_ACCESS_TOKEN) + val email = secureStore.read(KEY_USER_EMAIL) + if (!token.isNullOrBlank() && !email.isNullOrBlank()) { + val user = AuthUser( + id = secureStore.read(KEY_USER_ID) ?: "", + email = email, + name = secureStore.read(KEY_USER_NAME) ?: "", + plan = secureStore.read(KEY_USER_PLAN) ?: "free", + ) + _state.value = AuthState.LoggedIn(user) + } else { + _state.value = AuthState.LoggedOut + } + } + + // ── Auth operations ────────────────────────────────────── + + suspend fun login(email: String, password: String) { + _state.value = AuthState.Loading + try { + val body = client.json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("email" to email, "password" to password, "productId" to config.productId), + ) + val response = client.request("POST", "/auth/login", body, skipAuth = true) + val result = client.json.decodeFromString(response) + handleAuthResult(result) + } catch (e: Exception) { + _state.value = AuthState.Error(e.message ?: "Login failed") + } + } + + suspend fun register(name: String, email: String, password: String) { + _state.value = AuthState.Loading + try { + val body = client.json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf( + "email" to email, + "displayName" to name, + "password" to password, + "productId" to config.productId, + ), + ) + val response = client.request("POST", "/auth/register", body, skipAuth = true) + val result = client.json.decodeFromString(response) + handleAuthResult(result) + } catch (e: Exception) { + _state.value = AuthState.Error(e.message ?: "Registration failed") + } + } + + fun logout() { + clearAll() + _state.value = AuthState.LoggedOut + } + + // ── Token refresh (singleton guard) ────────────────────── + + private val refreshMutex = Mutex() + + suspend fun refreshAccessToken(): Boolean = refreshMutex.withLock { + val rt = getRefreshToken() ?: return false + return try { + val body = client.json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("refreshToken" to rt), + ) + val response = client.request("POST", "/auth/refresh", body, skipAuth = true) + val result = client.json.decodeFromString(response) + setTokens(result.accessToken, result.refreshToken) + true + } catch (e: BLApiException) { + if (e.statusCode == 401) { + withContext(Dispatchers.Main) { logout() } + } + false + } catch (_: Exception) { + false + } + } + + // ── Password management ────────────────────────────────── + + suspend fun forgotPassword(email: String): String { + val body = client.json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("email" to email, "productId" to config.productId), + ) + val response = client.request("POST", "/auth/forgot-password", body, skipAuth = true) + return client.json.decodeFromString(response).message + } + + suspend fun resetPassword(token: String, newPassword: String): String { + val body = client.json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("token" to token, "newPassword" to newPassword), + ) + val response = client.request("POST", "/auth/reset-password", body, skipAuth = true) + return client.json.decodeFromString(response).message + } + + suspend fun changePassword(currentPassword: String, newPassword: String): String { + val body = client.json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("currentPassword" to currentPassword, "newPassword" to newPassword), + ) + val response = client.request("POST", "/auth/change-password", body) + return client.json.decodeFromString(response).message + } + + // ── Email verification ─────────────────────────────────── + + suspend fun verifyEmail(token: String): String { + val body = client.json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("token" to token), + ) + val response = client.request("POST", "/auth/verify-email", body) + return client.json.decodeFromString(response).message + } + + suspend fun resendVerification(email: String): String { + val body = client.json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("email" to email, "productId" to config.productId), + ) + val response = client.request("POST", "/auth/resend-verification", body) + return client.json.decodeFromString(response).message + } + + // ── Account management ─────────────────────────────────── + + suspend fun deleteAccount(password: String): String { + val body = client.json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("password" to password), + ) + val response = client.request("DELETE", "/auth/account", body) + clearAll() + _state.value = AuthState.LoggedOut + return client.json.decodeFromString(response).message + } + + suspend fun getMe(): AuthUser { + val response = client.request("GET", "/auth/me") + return client.json.decodeFromString(response) + } + + // ── Private ────────────────────────────────────────────── + + private fun handleAuthResult(result: TokenResponse) { + setTokens(result.accessToken, result.refreshToken) + saveUser(result.user) + _state.value = AuthState.LoggedIn(result.user) + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt new file mode 100644 index 00000000..0da5b117 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBiometricAuth.kt @@ -0,0 +1,83 @@ +package com.bytelyst.platform + +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +/** + * Biometric authentication wrapper (Face / Fingerprint). + * + * Uses AndroidX BiometricPrompt for hardware-backed authentication. + * Mirrors the Swift BLBiometricAuth API. + */ +object BLBiometricAuth { + + enum class BiometricResult { + SUCCESS, + CANCELLED, + NOT_AVAILABLE, + ERROR, + } + + /** + * Check if biometric authentication is available on this device. + */ + fun isAvailable(activity: FragmentActivity): Boolean { + val manager = BiometricManager.from(activity) + return manager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == + BiometricManager.BIOMETRIC_SUCCESS + } + + /** + * Show a biometric prompt and return the result. + * + * Must be called from a FragmentActivity (Compose activities extend this). + */ + suspend fun authenticate( + activity: FragmentActivity, + title: String = "Authenticate", + subtitle: String? = null, + negativeButtonText: String = "Cancel", + ): BiometricResult { + if (!isAvailable(activity)) return BiometricResult.NOT_AVAILABLE + + return suspendCoroutine { continuation -> + val executor = ContextCompat.getMainExecutor(activity) + + val callback = object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + continuation.resume(BiometricResult.SUCCESS) + } + + override fun onAuthenticationError(errorCode: Int, errString: CharSequence) { + val result = if ( + errorCode == BiometricPrompt.ERROR_USER_CANCELED || + errorCode == BiometricPrompt.ERROR_NEGATIVE_BUTTON || + errorCode == BiometricPrompt.ERROR_CANCELED + ) { + BiometricResult.CANCELLED + } else { + BiometricResult.ERROR + } + continuation.resume(result) + } + + override fun onAuthenticationFailed() { + // Individual attempt failed, prompt stays open — don't resume yet + } + } + + val prompt = BiometricPrompt(activity, executor, callback) + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(title) + .apply { subtitle?.let { setSubtitle(it) } } + .setNegativeButtonText(negativeButtonText) + .build() + + prompt.authenticate(info) + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt new file mode 100644 index 00000000..b327730f --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLBlobClient.kt @@ -0,0 +1,93 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Azure Blob Storage client via platform-service SAS tokens. + * + * Flow: Get SAS token from POST /api/blob/sas → Upload directly to Azure Blob. + * Mirrors the Swift BLBlobClient API. + */ +class BLBlobClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + @Serializable + data class SasResponse( + val sasUrl: String, + val blobUrl: String, + ) + + private val json = Json { ignoreUnknownKeys = true } + + private val platformClient = BLPlatformClient(config, tokenProvider) + + private val uploadClient = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .writeTimeout(120, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + /** + * Upload data to Azure Blob Storage. + * + * @param data The raw bytes to upload. + * @param container Azure Blob container name (e.g., "audio", "attachments"). + * @param fileName The blob name / file path. + * @param contentType MIME type (e.g., "audio/wav", "image/png"). + * @return The public blob URL on success, or null on failure. + */ + suspend fun upload( + data: ByteArray, + container: String, + fileName: String, + contentType: String, + ): String? = withContext(Dispatchers.IO) { + try { + // Step 1: Get SAS token from platform-service + val sasBody = json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf( + "container" to container, + "blobName" to fileName, + "permissions" to "w", + "contentType" to contentType, + ), + ) + val sasResponse = platformClient.request("POST", "/blob/sas", sasBody) + val sas = json.decodeFromString(sasResponse) + + // Step 2: Upload directly to Azure Blob via SAS URL + val request = Request.Builder() + .url(sas.sasUrl) + .put(data.toRequestBody(contentType.toMediaType())) + .header("x-ms-blob-type", "BlockBlob") + .header("x-ms-blob-content-type", contentType) + .build() + + val response = uploadClient.newCall(request).execute() + if (response.isSuccessful) sas.blobUrl else null + } catch (_: Exception) { + null + } + } + + /** + * Upload audio data (convenience method). + */ + suspend fun uploadAudio(data: ByteArray, fileName: String): String? { + return upload(data, "audio", fileName, "audio/wav") + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt new file mode 100644 index 00000000..fabd0fe9 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLCrashReporter.kt @@ -0,0 +1,97 @@ +package com.bytelyst.platform + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID + +/** + * Crash reporter — captures uncaught exceptions and reports to telemetry. + * + * Installs a [Thread.UncaughtExceptionHandler] that: + * 1. Saves crash info to SharedPreferences + * 2. On next app launch, sends the crash report to platform-service telemetry + * 3. Delegates to the previous handler (so the system can still show ANR/crash dialogs) + * + * Mirrors the Swift BLCrashReporter API (MetricKit equivalent for Android). + */ +class BLCrashReporter( + context: Context, + private val config: BLPlatformConfig, +) { + private val prefs: SharedPreferences = + context.getSharedPreferences("${config.productId}_crash_reporter", Context.MODE_PRIVATE) + private val client = BLPlatformClient(config) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val json = Json { encodeDefaults = true } + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + companion object { + private const val KEY_PENDING_CRASH = "pending_crash" + } + + /** + * Install the crash handler and send any pending crash reports. + * Call once from Application.onCreate(). + */ + fun install() { + sendPendingCrashReport() + installHandler() + } + + private fun installHandler() { + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + val sw = StringWriter() + throwable.printStackTrace(PrintWriter(sw)) + + val crashData = buildJsonObject { + put("id", UUID.randomUUID().toString()) + put("productId", config.productId) + put("platform", config.platform) + put("timestamp", isoFormat.format(Date())) + put("thread", thread.name) + put("exception", throwable.javaClass.name) + put("message", throwable.message ?: "") + put("stackTrace", sw.toString().take(4096)) + } + + // Persist synchronously (we may be about to die) + prefs.edit().putString(KEY_PENDING_CRASH, json.encodeToString(crashData)).commit() + + // Delegate to previous handler + previousHandler?.uncaughtException(thread, throwable) + } + } + + private fun sendPendingCrashReport() { + val pending = prefs.getString(KEY_PENDING_CRASH, null) ?: return + prefs.edit().remove(KEY_PENDING_CRASH).apply() + + scope.launch { + try { + val body = """{"productId":"${config.productId}","events":[$pending]}""" + client.fireAndForget("POST", "/telemetry/events", body) + } catch (_: Exception) { + // Best-effort — don't re-queue + } + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt new file mode 100644 index 00000000..474ad0f9 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLFeatureFlagClient.kt @@ -0,0 +1,90 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Feature flag client for platform-service. + * + * Polls GET /api/flags/poll on a configurable interval and caches results + * in memory. Consumers call [isEnabled] with a flag key. + * + * Mirrors the Swift BLFeatureFlagClient API. + */ +class BLFeatureFlagClient( + private val config: BLPlatformConfig, + private val pollIntervalMs: Long = 5 * 60 * 1000L, +) { + @Serializable + private data class FlagResponse(val flags: Map = emptyMap()) + + private val json = Json { ignoreUnknownKeys = true } + private val client = BLPlatformClient(config) + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var pollJob: Job? = null + + @Volatile + private var flags: Map = emptyMap() + + private var userId: String? = null + + // ── Public API ─────────────────────────────────────────── + + /** + * Initialize and start polling. Call once at app startup. + */ + fun init(userId: String? = null) { + this.userId = userId + scope.launch { fetchFlags() } + startPolling() + } + + fun isEnabled(key: String): Boolean = flags[key] == true + + fun getAllFlags(): Map = flags.toMap() + + /** + * Force a refresh of feature flags. + */ + suspend fun refresh() { + fetchFlags() + } + + fun stop() { + pollJob?.cancel() + pollJob = null + } + + // ── Private ────────────────────────────────────────────── + + private fun startPolling() { + if (pollJob != null) return + pollJob = scope.launch { + while (isActive) { + delay(pollIntervalMs) + fetchFlags() + } + } + } + + private suspend fun fetchFlags() { + try { + val qs = buildString { + append("?platform=${config.platform}") + userId?.let { append("&userId=$it") } + } + val response = client.request("GET", "/flags/poll$qs", skipAuth = true) + val result = json.decodeFromString(response) + flags = result.flags + } catch (_: Exception) { + // Keep existing flags on failure + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt new file mode 100644 index 00000000..b4c86874 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLKillSwitchClient.kt @@ -0,0 +1,41 @@ +package com.bytelyst.platform + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Kill switch client for platform-service. + * + * Checks GET /api/flags/kill-switch to determine if the app should be disabled. + * Fail-open: returns [KillSwitchResult.ok] on any error. + * + * Mirrors the Swift BLKillSwitchClient API. + */ +class BLKillSwitchClient( + private val config: BLPlatformConfig, +) { + @Serializable + data class KillSwitchResult( + val disabled: Boolean = false, + val message: String? = null, + ) { + companion object { + fun ok() = KillSwitchResult(disabled = false, message = null) + } + } + + private val json = Json { ignoreUnknownKeys = true } + private val client = BLPlatformClient(config) + + /** + * Check if the app is disabled. Fail-open on any error. + */ + suspend fun check(): KillSwitchResult { + return try { + val response = client.request("GET", "/flags/kill-switch?platform=${config.platform}", skipAuth = true) + json.decodeFromString(response) + } catch (_: Exception) { + KillSwitchResult.ok() + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt new file mode 100644 index 00000000..189ecfb2 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLLicenseClient.kt @@ -0,0 +1,82 @@ +package com.bytelyst.platform + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.net.URLEncoder + +/** + * License client for platform-service. + * + * Activates and checks license keys via /api/licenses endpoints. + * URL-encodes the license key in the path to handle special characters. + * + * Mirrors the Swift BLLicenseClient API. + */ +class BLLicenseClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + @Serializable + data class LicenseStatus( + val valid: Boolean = false, + val plan: String = "free", + val expiresAt: String? = null, + val message: String? = null, + ) + + @Serializable + data class ActivationResult( + val success: Boolean = false, + val plan: String = "free", + val message: String? = null, + ) + + private val json = Json { ignoreUnknownKeys = true } + private val client = BLPlatformClient(config, tokenProvider) + + /** + * Activate a license key. + */ + suspend fun activate(licenseKey: String): ActivationResult { + return try { + val encoded = URLEncoder.encode(licenseKey, "UTF-8") + val body = json.encodeToString( + kotlinx.serialization.builtins.MapSerializer( + kotlinx.serialization.builtins.serializer(), + kotlinx.serialization.builtins.serializer(), + ), + mapOf("productId" to config.productId), + ) + val response = client.request("POST", "/licenses/$encoded/activate", body) + json.decodeFromString(response) + } catch (e: Exception) { + ActivationResult(success = false, message = e.message) + } + } + + /** + * Check the status of a license key. + */ + suspend fun checkStatus(licenseKey: String): LicenseStatus { + return try { + val encoded = URLEncoder.encode(licenseKey, "UTF-8") + val response = client.request("GET", "/licenses/$encoded/status") + json.decodeFromString(response) + } catch (e: Exception) { + LicenseStatus(valid = false, message = e.message) + } + } + + /** + * Deactivate a license key. + */ + suspend fun deactivate(licenseKey: String): Boolean { + return try { + val encoded = URLEncoder.encode(licenseKey, "UTF-8") + client.request("POST", "/licenses/$encoded/deactivate") + true + } catch (_: Exception) { + false + } + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt new file mode 100644 index 00000000..cc8334ae --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformClient.kt @@ -0,0 +1,99 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.UUID +import java.util.concurrent.TimeUnit + +/** + * Generic HTTP client for platform-service. + * + * Injects auth token, x-product-id, and x-request-id on every request. + * Supports fire-and-forget mode for telemetry-style calls. + */ +class BLPlatformClient( + private val config: BLPlatformConfig, + private val tokenProvider: () -> String? = { null }, +) { + val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) + .writeTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) + .readTimeout(config.timeoutMs, TimeUnit.MILLISECONDS) + .build() + + /** + * Execute a request and return the response body as a String. + * Throws on non-2xx responses. + */ + suspend fun request( + method: String, + path: String, + body: String? = null, + extraHeaders: Map = emptyMap(), + skipAuth: Boolean = false, + ): String = withContext(Dispatchers.IO) { + val builder = Request.Builder() + .url("${config.baseUrl}$path") + .header("Content-Type", "application/json") + .header("X-Product-Id", config.productId) + .header("X-Request-Id", UUID.randomUUID().toString()) + + if (!skipAuth) { + tokenProvider()?.let { token -> + builder.header("Authorization", "Bearer $token") + } + } + + extraHeaders.forEach { (k, v) -> builder.header(k, v) } + + val requestBody = body?.toRequestBody("application/json".toMediaType()) + when (method.uppercase()) { + "GET" -> builder.get() + "POST" -> builder.post(requestBody ?: "".toRequestBody(null)) + "PUT" -> builder.put(requestBody ?: "".toRequestBody(null)) + "DELETE" -> if (requestBody != null) builder.delete(requestBody) else builder.delete() + "PATCH" -> builder.patch(requestBody ?: "".toRequestBody(null)) + else -> builder.method(method, requestBody) + } + + val response = httpClient.newCall(builder.build()).execute() + val responseBody = response.body?.string() ?: "" + + if (!response.isSuccessful) { + throw BLApiException(response.code, responseBody) + } + + responseBody + } + + /** + * Fire-and-forget: execute request, silently swallow errors. + */ + suspend fun fireAndForget( + method: String, + path: String, + body: String? = null, + extraHeaders: Map = emptyMap(), + ) { + try { + request(method, path, body, extraHeaders) + } catch (_: Exception) { + // Silently swallow — fire-and-forget + } + } +} + +/** + * Exception thrown when the platform API returns a non-2xx status. + */ +class BLApiException( + val statusCode: Int, + val responseBody: String, +) : Exception("Platform API error $statusCode: $responseBody") diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt new file mode 100644 index 00000000..bf128db1 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLPlatformConfig.kt @@ -0,0 +1,27 @@ +package com.bytelyst.platform + +/** + * Product-specific configuration for the ByteLyst platform SDK. + * + * Each Android app creates one instance at startup and passes it to + * all SDK components via DI (Hilt, Koin, or manual). + */ +data class BLPlatformConfig( + /** Product identifier (e.g., "chronomind", "lysnrai", "mindlyst"). */ + val productId: String, + + /** Platform-service base URL (e.g., "http://localhost:4003/api"). */ + val baseUrl: String, + + /** Platform string for telemetry (e.g., "android", "wear_os"). */ + val platform: String = "android", + + /** Channel string for telemetry (e.g., "native", "keyboard"). */ + val channel: String = "native", + + /** Application ID / bundle ID (e.g., "com.chronomind.app"). */ + val applicationId: String, + + /** Request timeout in milliseconds. Default: 15 seconds. */ + val timeoutMs: Long = 15_000L, +) diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt new file mode 100644 index 00000000..2550beaa --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSecureStore.kt @@ -0,0 +1,51 @@ +package com.bytelyst.platform + +import android.content.Context +import android.content.SharedPreferences +import androidx.security.crypto.EncryptedSharedPreferences +import androidx.security.crypto.MasterKeys + +/** + * EncryptedSharedPreferences-backed secure storage. + * + * Replaces Keychain on iOS. Uses Android Keystore for key material. + * Each app gets its own namespace via [applicationId]. + */ +class BLSecureStore( + context: Context, + applicationId: String, +) { + private val prefs: SharedPreferences = try { + val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC) + EncryptedSharedPreferences.create( + "${applicationId}_secure_store", + masterKeyAlias, + context, + EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } catch (_: Exception) { + // Fallback to plain SharedPreferences if encryption fails (e.g., test environment) + context.getSharedPreferences("${applicationId}_secure_store_fallback", Context.MODE_PRIVATE) + } + + fun save(key: String, value: String): Boolean { + return prefs.edit().putString(key, value).commit() + } + + fun read(key: String): String? { + return prefs.getString(key, null) + } + + fun delete(key: String): Boolean { + return prefs.edit().remove(key).commit() + } + + fun clear(): Boolean { + return prefs.edit().clear().commit() + } + + fun contains(key: String): Boolean { + return prefs.contains(key) + } +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt new file mode 100644 index 00000000..060cbaf9 --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLSyncEngine.kt @@ -0,0 +1,111 @@ +package com.bytelyst.platform + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * Generic offline-first sync engine. + * + * Apps implement [BLSyncAdapter] for their specific data type, + * then [BLSyncEngine] handles the pull-merge-push cycle. + * + * Mirrors the Swift BLSyncEngine + BLSyncAdapter API. + */ +class BLSyncEngine( + private val adapter: BLSyncAdapter, + private val config: BLPlatformConfig, + tokenProvider: () -> String? = { null }, +) { + private val client = BLPlatformClient(config, tokenProvider) + + /** + * Run a full sync cycle: pull → merge → push. + * + * @return [SyncResult] with counts of pulled, pushed, and conflicted items. + */ + suspend fun sync(): SyncResult = withContext(Dispatchers.IO) { + var pulled = 0 + var pushed = 0 + var conflicts = 0 + + try { + // Step 1: Pull remote changes + val lastSync = adapter.getLastSyncTimestamp() + val pullPath = adapter.pullPath(lastSync) + val response = client.request("GET", pullPath) + val remoteItems = adapter.deserializePullResponse(response) + pulled = remoteItems.size + + // Step 2: Merge remote into local + for (item in remoteItems) { + val merged = adapter.merge(item) + if (!merged) conflicts++ + } + + // Step 3: Push local changes + val localChanges = adapter.getLocalChanges() + for (item in localChanges) { + try { + val body = adapter.serializeForPush(item) + val method = adapter.pushMethod(item) + val path = adapter.pushPath(item) + client.request(method, path, body) + adapter.markSynced(item) + pushed++ + } catch (_: Exception) { + // Individual push failure — will retry next sync + } + } + + // Step 4: Update last sync timestamp + adapter.setLastSyncTimestamp(System.currentTimeMillis()) + } catch (_: Exception) { + // Full sync failure — will retry next time + } + + SyncResult(pulled = pulled, pushed = pushed, conflicts = conflicts) + } + + data class SyncResult( + val pulled: Int = 0, + val pushed: Int = 0, + val conflicts: Int = 0, + ) +} + +/** + * Adapter interface for [BLSyncEngine]. + * + * Each app implements this for their domain model (timers, sessions, etc.). + */ +interface BLSyncAdapter { + /** Get the timestamp of the last successful sync (epoch millis), or null if never synced. */ + fun getLastSyncTimestamp(): Long? + + /** Set the last sync timestamp after a successful sync. */ + fun setLastSyncTimestamp(timestamp: Long) + + /** Build the pull endpoint path, optionally including a since parameter. */ + fun pullPath(since: Long?): String + + /** Deserialize the pull response JSON into a list of remote items. */ + fun deserializePullResponse(json: String): List + + /** Merge a remote item into local storage. Return true if merged cleanly, false if conflict. */ + fun merge(remoteItem: T): Boolean + + /** Get local items that have been modified since last sync. */ + fun getLocalChanges(): List + + /** Serialize a local item for push. */ + fun serializeForPush(item: T): String + + /** HTTP method for pushing an item (POST for new, PUT for update). */ + fun pushMethod(item: T): String + + /** Build the push endpoint path for an item. */ + fun pushPath(item: T): String + + /** Mark an item as synced (clear dirty flag). */ + fun markSynced(item: T) +} diff --git a/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt new file mode 100644 index 00000000..8b69c41f --- /dev/null +++ b/packages/kotlin-platform-sdk/src/main/kotlin/com/bytelyst/platform/BLTelemetryClient.kt @@ -0,0 +1,191 @@ +package com.bytelyst.platform + +import android.content.Context +import android.content.SharedPreferences +import android.os.Build +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import java.util.UUID + +/** + * Telemetry client for platform-service. + * + * Queues events locally and flushes in batches to POST /api/telemetry/events. + * Events persist in SharedPreferences to survive process death. + * Fire-and-forget: errors never surface to the user. + * + * Mirrors the Swift BLTelemetryClient API. + */ +class BLTelemetryClient( + private val config: BLPlatformConfig, + context: Context, + private val maxQueueSize: Int = 50, + private val flushIntervalMs: Long = 30_000L, +) { + // ── Event schema ───────────────────────────────────────── + + @Serializable + data class TelemetryEvent( + val id: String, + val productId: String, + val anonymousInstallId: String, + val sessionId: String, + val platform: String, + val channel: String, + val osFamily: String, + val osVersion: String, + val appVersion: String, + val buildNumber: String, + val releaseChannel: String, + val eventType: String, + val module: String, + val eventName: String, + val feature: String? = null, + val message: String? = null, + val errorCode: String? = null, + val errorDomain: String? = null, + val tags: Map? = null, + val metrics: Map? = null, + val occurredAt: String, + ) + + @Serializable + private data class EventBatch(val events: List) + + // ── State ──────────────────────────────────────────────── + + private val prefs: SharedPreferences = + context.getSharedPreferences("${config.productId}_telemetry", Context.MODE_PRIVATE) + private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private val queue = mutableListOf() + private var flushJob: Job? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private var sessionId = UUID.randomUUID().toString() + + private val client = BLPlatformClient(config) + + private val installId: String by lazy { + val key = "install_id" + prefs.getString(key, null) ?: UUID.randomUUID().toString().also { + prefs.edit().putString(key, it).apply() + } + } + + private val osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})" + private val appVersion: String + private val buildNumber: String + + init { + val pm = context.packageManager + val pi = try { pm.getPackageInfo(context.packageName, 0) } catch (_: Exception) { null } + appVersion = pi?.versionName ?: "0.0.0" + buildNumber = (pi?.longVersionCode ?: 0).toString() + } + + private val isoFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + + // ── Lifecycle ──────────────────────────────────────────── + + fun start() { + if (flushJob != null) return + flushJob = scope.launch { + while (isActive) { + delay(flushIntervalMs) + flush() + } + } + } + + fun stop() { + flush() + flushJob?.cancel() + flushJob = null + } + + fun newSession() { + sessionId = UUID.randomUUID().toString() + } + + // ── Public API ─────────────────────────────────────────── + + fun trackEvent( + eventType: String, + module: String, + name: String, + feature: String? = null, + message: String? = null, + errorCode: String? = null, + errorDomain: String? = null, + tags: Map? = null, + metrics: Map? = null, + ) { + val event = TelemetryEvent( + id = UUID.randomUUID().toString(), + productId = config.productId, + anonymousInstallId = installId, + sessionId = sessionId, + platform = config.platform, + channel = config.channel, + osFamily = "android", + osVersion = osVersion, + appVersion = appVersion, + buildNumber = buildNumber, + releaseChannel = "beta", + eventType = eventType, + module = module, + eventName = name, + feature = feature, + message = message?.take(512), + errorCode = errorCode, + errorDomain = errorDomain, + tags = tags, + metrics = metrics, + occurredAt = isoFormat.format(Date()), + ) + + synchronized(queue) { + queue.add(event) + if (queue.size >= maxQueueSize) { + flush() + } + } + } + + fun trackScreen(screen: String) { + trackEvent("info", "navigation", "screen_view", tags = mapOf("screen" to screen)) + } + + // ── Flush ──────────────────────────────────────────────── + + fun flush() { + val batch: List + synchronized(queue) { + if (queue.isEmpty()) return + batch = queue.toList() + queue.clear() + } + + scope.launch { + val body = json.encodeToString(EventBatch(batch)) + client.fireAndForget( + "POST", "/telemetry/events", body, + extraHeaders = mapOf("X-Install-Token" to installId), + ) + } + } +} diff --git a/packages/offline-queue/package.json b/packages/offline-queue/package.json new file mode 100644 index 00000000..7af0c3bd --- /dev/null +++ b/packages/offline-queue/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bytelyst/offline-queue", + "version": "0.1.0", + "type": "module", + "description": "Browser/React Native-safe persistent offline retry queue", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + } +} diff --git a/packages/offline-queue/src/index.test.ts b/packages/offline-queue/src/index.test.ts new file mode 100644 index 00000000..21356222 --- /dev/null +++ b/packages/offline-queue/src/index.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createOfflineQueue } from './index.js'; + +function createMemoryStorage() { + const store: Record = {}; + return { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => { + store[k] = v; + }, + _store: store, + }; +} + +describe('createOfflineQueue', () => { + it('should start with zero length', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + expect(queue.length()).toBe(0); + }); + + it('should enqueue items', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { name: 'test' } }); + expect(queue.length()).toBe(1); + + queue.enqueue({ id: '2', action: 'update', path: '/sessions/2', payload: { name: 'test2' } }); + expect(queue.length()).toBe(2); + }); + + it('should replace existing entry with same id + action', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 1 } }); + queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: { v: 2 } }); + expect(queue.length()).toBe(1); + }); + + it('should not replace entries with different action', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/sessions', payload: {} }); + queue.enqueue({ id: '1', action: 'update', path: '/sessions/1', payload: {} }); + expect(queue.length()).toBe(2); + }); + + it('should flush all items successfully', async () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { n: 1 } }); + queue.enqueue({ id: '2', action: 'create', path: '/b', payload: { n: 2 } }); + + const executor = vi.fn().mockResolvedValue(undefined); + const result = await queue.flush(executor); + + expect(result.flushed).toBe(2); + expect(result.failed).toBe(0); + expect(queue.length()).toBe(0); + expect(executor).toHaveBeenCalledTimes(2); + }); + + it('should retry failed items on next flush', async () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 3 }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); + + const failingExecutor = vi.fn().mockRejectedValue(new Error('offline')); + const result = await queue.flush(failingExecutor); + + expect(result.flushed).toBe(0); + expect(result.failed).toBe(1); + expect(queue.length()).toBe(1); + }); + + it('should drop items after max retries', async () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxRetries: 2 }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); + + const failingExecutor = vi.fn().mockRejectedValue(new Error('offline')); + + // Retry 1 + await queue.flush(failingExecutor); + expect(queue.length()).toBe(1); + + // Retry 2 — should be dropped + await queue.flush(failingExecutor); + expect(queue.length()).toBe(0); + }); + + it('should cap queue at maxQueueSize', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage, maxQueueSize: 3 }); + + for (let i = 0; i < 5; i++) { + queue.enqueue({ id: `${i}`, action: 'create', path: `/item/${i}`, payload: {} }); + } + + expect(queue.length()).toBe(3); + }); + + it('should persist to storage', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: { x: 1 } }); + + const stored = JSON.parse(storage._store['test-q']); + expect(stored).toHaveLength(1); + expect(stored[0].id).toBe('1'); + }); + + it('should clear the queue', () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + queue.enqueue({ id: '1', action: 'create', path: '/a', payload: {} }); + queue.enqueue({ id: '2', action: 'create', path: '/b', payload: {} }); + expect(queue.length()).toBe(2); + + queue.clear(); + expect(queue.length()).toBe(0); + }); + + it('should return empty result when flushing empty queue', async () => { + const storage = createMemoryStorage(); + const queue = createOfflineQueue({ storageKey: 'test-q', storage }); + + const executor = vi.fn(); + const result = await queue.flush(executor); + + expect(result.flushed).toBe(0); + expect(result.failed).toBe(0); + expect(executor).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/offline-queue/src/index.ts b/packages/offline-queue/src/index.ts new file mode 100644 index 00000000..d11a1d0b --- /dev/null +++ b/packages/offline-queue/src/index.ts @@ -0,0 +1,166 @@ +/** + * Persistent offline retry queue for browser and React Native. + * + * When an API call fails (offline, timeout, etc.), the operation is + * queued in configurable storage and retried on the next flush. + * + * No Node.js, React, or React Native dependencies. + * + * @example + * ```ts + * import { createOfflineQueue } from '@bytelyst/offline-queue'; + * + * const queue = createOfflineQueue({ + * storageKey: 'nomgap-offline-queue', + * storage: mmkvStorage, // or localStorage + * }); + * + * // On API failure: + * queue.enqueue({ id: 'sess-1', action: 'create', path: '/sessions', payload: { ... } }); + * + * // On app foreground / auth success: + * const result = await queue.flush(async (action, path, payload) => { + * await apiClient.request(action === 'create' ? 'POST' : 'PUT', path, payload); + * }); + * ``` + */ + +// ── Types ──────────────────────────────────────────────────── + +export interface QueueStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; +} + +export interface OfflineQueueConfig { + /** Storage key for persisting the queue. */ + storageKey: string; + + /** Storage adapter (localStorage, MMKV, AsyncStorage wrapper, etc.). */ + storage: QueueStorage; + + /** Maximum retry attempts per item. Default: 5. */ + maxRetries?: number; + + /** Maximum queue size. Oldest items are dropped when exceeded. Default: 50. */ + maxQueueSize?: number; +} + +export interface QueueItem { + id: string; + action: string; + path: string; + payload: Record; + enqueuedAt: number; + retryCount: number; +} + +export interface FlushResult { + flushed: number; + failed: number; +} + +export interface OfflineQueue { + /** Enqueue a failed operation for later retry. Replaces existing entry with same id + action. */ + enqueue(item: { + id: string; + action: string; + path: string; + payload: Record; + }): void; + + /** Flush the queue — retry all pending items via the provided executor. */ + flush( + executor: (action: string, path: string, payload: Record) => Promise + ): Promise; + + /** Get current queue length. */ + length(): number; + + /** Clear the entire queue. */ + clear(): void; +} + +// ── Factory ────────────────────────────────────────────────── + +export function createOfflineQueue(config: OfflineQueueConfig): OfflineQueue { + const { storageKey, storage, maxRetries = 5, maxQueueSize = 50 } = config; + + function loadQueue(): QueueItem[] { + try { + const raw = storage.getItem(storageKey); + if (!raw) return []; + return JSON.parse(raw) as QueueItem[]; + } catch { + return []; + } + } + + function saveQueue(queue: QueueItem[]): void { + try { + storage.setItem(storageKey, JSON.stringify(queue)); + } catch { + // Storage unavailable + } + } + + function enqueue(item: { + id: string; + action: string; + path: string; + payload: Record; + }): void { + const queue = loadQueue(); + + // Replace existing entry for same entity + action + const filtered = queue.filter(q => !(q.id === item.id && q.action === item.action)); + + // Cap queue size + if (filtered.length >= maxQueueSize) { + filtered.shift(); + } + + filtered.push({ + ...item, + enqueuedAt: Date.now(), + retryCount: 0, + }); + + saveQueue(filtered); + } + + async function flush( + executor: (action: string, path: string, payload: Record) => Promise + ): Promise { + const queue = loadQueue(); + if (queue.length === 0) return { flushed: 0, failed: 0 }; + + let flushed = 0; + const remaining: QueueItem[] = []; + + for (const item of queue) { + try { + await executor(item.action, item.path, item.payload); + flushed++; + } catch { + if (item.retryCount + 1 < maxRetries) { + remaining.push({ ...item, retryCount: item.retryCount + 1 }); + } + // else: silently drop — too many retries + } + } + + saveQueue(remaining); + return { flushed, failed: remaining.length }; + } + + function length(): number { + return loadQueue().length; + } + + function clear(): void { + saveQueue([]); + } + + return { enqueue, flush, length, clear }; +} diff --git a/packages/offline-queue/tsconfig.json b/packages/offline-queue/tsconfig.json new file mode 100644 index 00000000..5a24989c --- /dev/null +++ b/packages/offline-queue/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/platform-client/package.json b/packages/platform-client/package.json new file mode 100644 index 00000000..8f5855b7 --- /dev/null +++ b/packages/platform-client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@bytelyst/platform-client", + "version": "0.1.0", + "type": "module", + "description": "Browser/React Native-safe typed fetch wrapper for platform-service", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "test": "vitest run" + } +} diff --git a/packages/platform-client/src/index.test.ts b/packages/platform-client/src/index.test.ts new file mode 100644 index 00000000..e39a0cf1 --- /dev/null +++ b/packages/platform-client/src/index.test.ts @@ -0,0 +1,162 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { createPlatformClient, ApiError } from './index.js'; + +describe('createPlatformClient', () => { + const baseConfig = { + baseUrl: 'http://localhost:4003/api', + productId: 'testapp', + getAccessToken: () => 'test-token', + }; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should make GET requests with auth header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ items: [] }), + }); + vi.stubGlobal('fetch', fetchMock); + + const api = createPlatformClient(baseConfig); + const result = await api.get('/items'); + + expect(result).toEqual({ items: [] }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/items', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + 'x-product-id': 'testapp', + }), + }) + ); + }); + + it('should make POST requests with body', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ id: '1' }), + }); + vi.stubGlobal('fetch', fetchMock); + + const api = createPlatformClient(baseConfig); + const result = await api.post('/items', { name: 'test' }); + + expect(result).toEqual({ id: '1' }); + expect(fetchMock).toHaveBeenCalledWith( + 'http://localhost:4003/api/items', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ name: 'test' }), + }) + ); + }); + + it('should handle 204 No Content', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: true, + status: 204, + json: () => Promise.reject(new Error('no body')), + }) + ); + + const api = createPlatformClient(baseConfig); + const result = await api.del('/items/1'); + + expect(result).toBeUndefined(); + }); + + it('should throw ApiError on non-OK response', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: () => Promise.resolve({ message: 'Bad request' }), + }) + ); + + const api = createPlatformClient(baseConfig); + + await expect(api.get('/items')).rejects.toThrow(ApiError); + try { + await api.get('/items'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).status).toBe(400); + } + }); + + it('should attempt token refresh on 401', async () => { + let callCount = 0; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation(() => { + callCount++; + if (callCount === 1) { + return Promise.resolve({ + ok: false, + status: 401, + json: () => Promise.resolve({ message: 'Unauthorized' }), + }); + } + return Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve({ refreshed: true }), + }); + }) + ); + + const refreshFn = vi.fn().mockResolvedValue(true); + const api = createPlatformClient({ + ...baseConfig, + refreshAccessToken: refreshFn, + }); + + const result = await api.get('/items'); + expect(refreshFn).toHaveBeenCalledOnce(); + expect(result).toEqual({ refreshed: true }); + }); + + it('should not include auth header when token is null', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); + vi.stubGlobal('fetch', fetchMock); + + const api = createPlatformClient({ + ...baseConfig, + getAccessToken: () => null, + }); + await api.get('/public/items'); + + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers['Authorization']).toBeUndefined(); + }); + + it('should include x-request-id header', async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({}), + }); + vi.stubGlobal('fetch', fetchMock); + + const api = createPlatformClient(baseConfig); + await api.get('/items'); + + const headers = fetchMock.mock.calls[0][1].headers as Record; + expect(headers['x-request-id']).toBeDefined(); + expect(headers['x-request-id'].length).toBeGreaterThan(0); + }); +}); diff --git a/packages/platform-client/src/index.ts b/packages/platform-client/src/index.ts new file mode 100644 index 00000000..ca87d7d0 --- /dev/null +++ b/packages/platform-client/src/index.ts @@ -0,0 +1,156 @@ +/** + * Browser/React Native-safe typed fetch wrapper for platform-service. + * + * Client-side counterpart to @bytelyst/api-client (which is server-side). + * Uses bearer tokens from storage (not httpOnly cookies). + * Includes auto-retry on 401 via token refresh. + * + * @example + * ```ts + * import { createPlatformClient } from '@bytelyst/platform-client'; + * + * const api = createPlatformClient({ + * baseUrl: 'http://localhost:4003/api', + * productId: 'nomgap', + * getAccessToken: () => authClient.getAccessToken(), + * refreshAccessToken: () => authClient.refreshAccessToken(), + * }); + * + * const sessions = await api.get('/fasting-sessions'); + * await api.post('/fasting-sessions', { protocol: '16:8' }); + * ``` + */ + +// ── Types ──────────────────────────────────────────────────── + +export interface PlatformClientConfig { + /** Platform-service base URL (e.g. "http://localhost:4003/api"). */ + baseUrl: string; + + /** Product identifier sent as x-product-id header. */ + productId: string; + + /** Function that returns the current access token, or null. */ + getAccessToken: () => string | null; + + /** Optional function to refresh the access token. Returns true on success. */ + refreshAccessToken?: () => Promise; + + /** Request timeout in milliseconds. Default: 15000. */ + timeoutMs?: number; +} + +export class ApiError extends Error { + constructor( + public readonly status: number, + public readonly body: unknown, + message?: string + ) { + super(message ?? `API error ${status}`); + this.name = 'ApiError'; + } +} + +export interface PlatformClient { + get(path: string, headers?: Record): Promise; + post(path: string, body?: unknown, headers?: Record): Promise; + put(path: string, body?: unknown, headers?: Record): Promise; + del(path: string, headers?: Record): Promise; + request( + method: string, + path: string, + body?: unknown, + headers?: Record + ): Promise; +} + +// ── UUID helper ────────────────────────────────────────────── + +function uuid(): string { + if (typeof globalThis.crypto?.randomUUID === 'function') { + return globalThis.crypto.randomUUID(); + } + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = (Math.random() * 16) | 0; + return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16); + }); +} + +// ── Factory ────────────────────────────────────────────────── + +export function createPlatformClient(config: PlatformClientConfig): PlatformClient { + const { baseUrl, productId, getAccessToken, refreshAccessToken, timeoutMs = 15_000 } = config; + + async function doRequest( + method: string, + path: string, + body?: unknown, + extraHeaders?: Record, + isRetry = false + ): Promise { + const url = `${baseUrl}${path}`; + const headers: Record = { + 'Content-Type': 'application/json', + 'x-product-id': productId, + 'x-request-id': uuid(), + ...extraHeaders, + }; + + const token = getAccessToken(); + if (token) headers['Authorization'] = `Bearer ${token}`; + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await globalThis.fetch(url, { + method, + headers, + body: body != null ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + if (res.status === 204) return undefined as T; + + const json = await res.json().catch(() => ({})); + + // Auto-refresh on 401 (once) + if (res.status === 401 && !isRetry && !path.startsWith('/auth/') && refreshAccessToken) { + clearTimeout(timer); + const refreshed = await refreshAccessToken(); + if (refreshed) { + return doRequest(method, path, body, extraHeaders, true); + } + } + + if (!res.ok) { + throw new ApiError( + res.status, + json, + (json as Record).message ?? `HTTP ${res.status}` + ); + } + + return json as T; + } finally { + clearTimeout(timer); + } + } + + return { + get: (path: string, headers?: Record) => + doRequest('GET', path, undefined, headers), + post: (path: string, body?: unknown, headers?: Record) => + doRequest('POST', path, body, headers), + put: (path: string, body?: unknown, headers?: Record) => + doRequest('PUT', path, body, headers), + del: (path: string, headers?: Record) => + doRequest('DELETE', path, undefined, headers), + request: ( + method: string, + path: string, + body?: unknown, + headers?: Record + ) => doRequest(method, path, body, headers), + }; +} diff --git a/packages/platform-client/tsconfig.json b/packages/platform-client/tsconfig.json new file mode 100644 index 00000000..5a24989c --- /dev/null +++ b/packages/platform-client/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ea7d109..a8b3815e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -379,10 +379,18 @@ importers: specifier: ^10.6.0 version: 10.6.0(fastify@5.7.4) + packages/feature-flag-client: {} + + packages/kill-switch-client: {} + packages/logger: {} packages/monitoring: {} + packages/offline-queue: {} + + packages/platform-client: {} + packages/react-auth: dependencies: '@bytelyst/api-client':