feat(sdk): add kotlin-platform-sdk (13 components) + 4 new TS client packages (32 tests)
This commit is contained in:
parent
92a6929238
commit
91c48a7bc7
21
packages/feature-flag-client/package.json
Normal file
21
packages/feature-flag-client/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
165
packages/feature-flag-client/src/client.test.ts
Normal file
165
packages/feature-flag-client/src/client.test.ts
Normal file
@ -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<string, string> = {};
|
||||||
|
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<string, string> = {
|
||||||
|
'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({});
|
||||||
|
});
|
||||||
|
});
|
||||||
110
packages/feature-flag-client/src/client.ts
Normal file
110
packages/feature-flag-client/src/client.ts
Normal file
@ -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<string, boolean> = {};
|
||||||
|
let initialized = false;
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | 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<void> {
|
||||||
|
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<string, boolean> };
|
||||||
|
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<void> {
|
||||||
|
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<Record<string, boolean>> {
|
||||||
|
return flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(): Promise<void> {
|
||||||
|
await fetchFlags();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(): void {
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
intervalId = null;
|
||||||
|
flags = {};
|
||||||
|
initialized = false;
|
||||||
|
userId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, isEnabled, getAllFlags, refresh, stop };
|
||||||
|
}
|
||||||
2
packages/feature-flag-client/src/index.ts
Normal file
2
packages/feature-flag-client/src/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { createFeatureFlagClient } from './client.js';
|
||||||
|
export type { FeatureFlagClient, FeatureFlagClientConfig } from './types.js';
|
||||||
44
packages/feature-flag-client/src/types.ts
Normal file
44
packages/feature-flag-client/src/types.ts
Normal file
@ -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<void>;
|
||||||
|
|
||||||
|
/** Check if a feature flag is enabled. Returns false if not found. */
|
||||||
|
isEnabled(key: string): boolean;
|
||||||
|
|
||||||
|
/** Get all currently cached flags. */
|
||||||
|
getAllFlags(): Readonly<Record<string, boolean>>;
|
||||||
|
|
||||||
|
/** Force a refresh of feature flags. */
|
||||||
|
refresh(): Promise<void>;
|
||||||
|
|
||||||
|
/** Stop polling and reset state. */
|
||||||
|
stop(): void;
|
||||||
|
}
|
||||||
8
packages/feature-flag-client/tsconfig.json
Normal file
8
packages/feature-flag-client/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
21
packages/kill-switch-client/package.json
Normal file
21
packages/kill-switch-client/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
102
packages/kill-switch-client/src/index.test.ts
Normal file
102
packages/kill-switch-client/src/index.test.ts
Normal file
@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
68
packages/kill-switch-client/src/index.ts
Normal file
68
packages/kill-switch-client/src/index.ts
Normal file
@ -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<KillSwitchResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createKillSwitchClient(config: KillSwitchClientConfig): KillSwitchClient {
|
||||||
|
const { baseUrl, productId, platform = 'mobile' } = config;
|
||||||
|
|
||||||
|
async function check(): Promise<KillSwitchResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
8
packages/kill-switch-client/tsconfig.json
Normal file
8
packages/kill-switch-client/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
57
packages/kotlin-platform-sdk/build.gradle.kts
Normal file
57
packages/kotlin-platform-sdk/build.gradle.kts
Normal file
@ -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<Test> {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
3
packages/kotlin-platform-sdk/consumer-rules.pro
Normal file
3
packages/kotlin-platform-sdk/consumer-rules.pro
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# ByteLyst Platform SDK — consumer ProGuard rules
|
||||||
|
# Keep all SDK public API classes
|
||||||
|
-keep class com.bytelyst.platform.** { *; }
|
||||||
1
packages/kotlin-platform-sdk/gradle.properties
Normal file
1
packages/kotlin-platform-sdk/gradle.properties
Normal file
@ -0,0 +1 @@
|
|||||||
|
android.useAndroidX=true
|
||||||
1
packages/kotlin-platform-sdk/settings.gradle.kts
Normal file
1
packages/kotlin-platform-sdk/settings.gradle.kts
Normal file
@ -0,0 +1 @@
|
|||||||
|
rootProject.name = "kotlin-platform-sdk"
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<!-- No permissions needed at the library level.
|
||||||
|
Consumer apps declare their own permissions. -->
|
||||||
|
</manifest>
|
||||||
@ -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<AuditEntry> {
|
||||||
|
return try {
|
||||||
|
if (!currentFile.exists()) return emptyList()
|
||||||
|
currentFile.readLines()
|
||||||
|
.filter { it.isNotBlank() }
|
||||||
|
.mapNotNull {
|
||||||
|
try { json.decodeFromString<AuditEntry>(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>(AuthState.Loading)
|
||||||
|
val state: StateFlow<AuthState> = _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<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
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<TokenResponse>(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<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
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<TokenResponse>(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<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("refreshToken" to rt),
|
||||||
|
)
|
||||||
|
val response = client.request("POST", "/auth/refresh", body, skipAuth = true)
|
||||||
|
val result = client.json.decodeFromString<RefreshResponse>(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<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("email" to email, "productId" to config.productId),
|
||||||
|
)
|
||||||
|
val response = client.request("POST", "/auth/forgot-password", body, skipAuth = true)
|
||||||
|
return client.json.decodeFromString<MessageResponse>(response).message
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resetPassword(token: String, newPassword: String): String {
|
||||||
|
val body = client.json.encodeToString(
|
||||||
|
kotlinx.serialization.builtins.MapSerializer(
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("token" to token, "newPassword" to newPassword),
|
||||||
|
)
|
||||||
|
val response = client.request("POST", "/auth/reset-password", body, skipAuth = true)
|
||||||
|
return client.json.decodeFromString<MessageResponse>(response).message
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun changePassword(currentPassword: String, newPassword: String): String {
|
||||||
|
val body = client.json.encodeToString(
|
||||||
|
kotlinx.serialization.builtins.MapSerializer(
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("currentPassword" to currentPassword, "newPassword" to newPassword),
|
||||||
|
)
|
||||||
|
val response = client.request("POST", "/auth/change-password", body)
|
||||||
|
return client.json.decodeFromString<MessageResponse>(response).message
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Email verification ───────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun verifyEmail(token: String): String {
|
||||||
|
val body = client.json.encodeToString(
|
||||||
|
kotlinx.serialization.builtins.MapSerializer(
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("token" to token),
|
||||||
|
)
|
||||||
|
val response = client.request("POST", "/auth/verify-email", body)
|
||||||
|
return client.json.decodeFromString<MessageResponse>(response).message
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resendVerification(email: String): String {
|
||||||
|
val body = client.json.encodeToString(
|
||||||
|
kotlinx.serialization.builtins.MapSerializer(
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("email" to email, "productId" to config.productId),
|
||||||
|
)
|
||||||
|
val response = client.request("POST", "/auth/resend-verification", body)
|
||||||
|
return client.json.decodeFromString<MessageResponse>(response).message
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Account management ───────────────────────────────────
|
||||||
|
|
||||||
|
suspend fun deleteAccount(password: String): String {
|
||||||
|
val body = client.json.encodeToString(
|
||||||
|
kotlinx.serialization.builtins.MapSerializer(
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("password" to password),
|
||||||
|
)
|
||||||
|
val response = client.request("DELETE", "/auth/account", body)
|
||||||
|
clearAll()
|
||||||
|
_state.value = AuthState.LoggedOut
|
||||||
|
return client.json.decodeFromString<MessageResponse>(response).message
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getMe(): AuthUser {
|
||||||
|
val response = client.request("GET", "/auth/me")
|
||||||
|
return client.json.decodeFromString<AuthUser>(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Private ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun handleAuthResult(result: TokenResponse) {
|
||||||
|
setTokens(result.accessToken, result.refreshToken)
|
||||||
|
saveUser(result.user)
|
||||||
|
_state.value = AuthState.LoggedIn(result.user)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
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>(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String, Boolean> = 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<String, Boolean> = 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<String, Boolean> = 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<FlagResponse>(response)
|
||||||
|
flags = result.flags
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// Keep existing flags on failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<KillSwitchResult>(response)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
KillSwitchResult.ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String>(),
|
||||||
|
kotlinx.serialization.builtins.serializer<String>(),
|
||||||
|
),
|
||||||
|
mapOf("productId" to config.productId),
|
||||||
|
)
|
||||||
|
val response = client.request("POST", "/licenses/$encoded/activate", body)
|
||||||
|
json.decodeFromString<ActivationResult>(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<LicenseStatus>(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String, String> = 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<String, String> = 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")
|
||||||
@ -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,
|
||||||
|
)
|
||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<T>(
|
||||||
|
private val adapter: BLSyncAdapter<T>,
|
||||||
|
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<T> {
|
||||||
|
/** 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<T>
|
||||||
|
|
||||||
|
/** 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<T>
|
||||||
|
|
||||||
|
/** 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)
|
||||||
|
}
|
||||||
@ -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<String, String>? = null,
|
||||||
|
val metrics: Map<String, Double>? = null,
|
||||||
|
val occurredAt: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class EventBatch(val events: List<TelemetryEvent>)
|
||||||
|
|
||||||
|
// ── 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<TelemetryEvent>()
|
||||||
|
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<String, String>? = null,
|
||||||
|
metrics: Map<String, Double>? = 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<TelemetryEvent>
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
21
packages/offline-queue/package.json
Normal file
21
packages/offline-queue/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
143
packages/offline-queue/src/index.test.ts
Normal file
143
packages/offline-queue/src/index.test.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { createOfflineQueue } from './index.js';
|
||||||
|
|
||||||
|
function createMemoryStorage() {
|
||||||
|
const store: Record<string, string> = {};
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
166
packages/offline-queue/src/index.ts
Normal file
166
packages/offline-queue/src/index.ts
Normal file
@ -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<string, unknown>;
|
||||||
|
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<string, unknown>;
|
||||||
|
}): void;
|
||||||
|
|
||||||
|
/** Flush the queue — retry all pending items via the provided executor. */
|
||||||
|
flush(
|
||||||
|
executor: (action: string, path: string, payload: Record<string, unknown>) => Promise<void>
|
||||||
|
): Promise<FlushResult>;
|
||||||
|
|
||||||
|
/** 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<string, unknown>;
|
||||||
|
}): 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<string, unknown>) => Promise<void>
|
||||||
|
): Promise<FlushResult> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
8
packages/offline-queue/tsconfig.json
Normal file
8
packages/offline-queue/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
21
packages/platform-client/package.json
Normal file
21
packages/platform-client/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
162
packages/platform-client/src/index.test.ts
Normal file
162
packages/platform-client/src/index.test.ts
Normal file
@ -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<string, string>;
|
||||||
|
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<string, string>;
|
||||||
|
expect(headers['x-request-id']).toBeDefined();
|
||||||
|
expect(headers['x-request-id'].length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
156
packages/platform-client/src/index.ts
Normal file
156
packages/platform-client/src/index.ts
Normal file
@ -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<Session[]>('/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<boolean>;
|
||||||
|
|
||||||
|
/** 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<T = unknown>(path: string, headers?: Record<string, string>): Promise<T>;
|
||||||
|
post<T = unknown>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
|
||||||
|
put<T = unknown>(path: string, body?: unknown, headers?: Record<string, string>): Promise<T>;
|
||||||
|
del<T = unknown>(path: string, headers?: Record<string, string>): Promise<T>;
|
||||||
|
request<T = unknown>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
): Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
extraHeaders?: Record<string, string>,
|
||||||
|
isRetry = false
|
||||||
|
): Promise<T> {
|
||||||
|
const url = `${baseUrl}${path}`;
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'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<T>(method, path, body, extraHeaders, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
res.status,
|
||||||
|
json,
|
||||||
|
(json as Record<string, string>).message ?? `HTTP ${res.status}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return json as T;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get: <T = unknown>(path: string, headers?: Record<string, string>) =>
|
||||||
|
doRequest<T>('GET', path, undefined, headers),
|
||||||
|
post: <T = unknown>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||||
|
doRequest<T>('POST', path, body, headers),
|
||||||
|
put: <T = unknown>(path: string, body?: unknown, headers?: Record<string, string>) =>
|
||||||
|
doRequest<T>('PUT', path, body, headers),
|
||||||
|
del: <T = unknown>(path: string, headers?: Record<string, string>) =>
|
||||||
|
doRequest<T>('DELETE', path, undefined, headers),
|
||||||
|
request: <T = unknown>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
headers?: Record<string, string>
|
||||||
|
) => doRequest<T>(method, path, body, headers),
|
||||||
|
};
|
||||||
|
}
|
||||||
8
packages/platform-client/tsconfig.json
Normal file
8
packages/platform-client/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@ -379,10 +379,18 @@ importers:
|
|||||||
specifier: ^10.6.0
|
specifier: ^10.6.0
|
||||||
version: 10.6.0(fastify@5.7.4)
|
version: 10.6.0(fastify@5.7.4)
|
||||||
|
|
||||||
|
packages/feature-flag-client: {}
|
||||||
|
|
||||||
|
packages/kill-switch-client: {}
|
||||||
|
|
||||||
packages/logger: {}
|
packages/logger: {}
|
||||||
|
|
||||||
packages/monitoring: {}
|
packages/monitoring: {}
|
||||||
|
|
||||||
|
packages/offline-queue: {}
|
||||||
|
|
||||||
|
packages/platform-client: {}
|
||||||
|
|
||||||
packages/react-auth:
|
packages/react-auth:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@bytelyst/api-client':
|
'@bytelyst/api-client':
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user