fix: red-team round 3 hardening across desktop, mobile, core and server

Batch of red-team r3 fixes that were in the working tree before the
2026-09-28 design overhaul, committed as one unit with their tests.

- desktop main: STT timeouts and sidecar, voice recording store, sync
  (credentials, audio, knowledge reindex, push gates), runtime
  provisioner, update policy, AltGr keybindings, voice-command policy,
  dictionary file codec/limits, meeting transcript condensing and a
  local recording ledger so interrupted-session recovery only closes
  meetings this device recorded (a phone's live meeting is left alone).
- mobile: login CSRF via implicit token callbacks rejected, account
  deletion/retention, durable queue retention, knowledge realtime
  without unfiltered DELETE, meeting re-record failure paths, cloud STT
  client, preferences store/resync.
- core: text chunking splits long unbroken transcripts to fit, template
  field policy, dictionary limits, meeting markdown inline handling.
- server: payple webhook policy and cancellation order scope, meeting
  document generation quota, team RPC null-role guard, unified LLM
  quota in-flight accounting, knowledge chunk vector index, meeting
  re-record failure paths (migrations 20260929*).
- ci: portable/runtime feed gates, update-policy schema, Forgejo file
  delete and alias planning.

Four older tests are updated to the new contracts rather than the old
behavior: token-pair auth callbacks are rejected, knowledge realtime no
longer subscribes to DELETE, long transcript lines are split, and
meeting recovery requires the local recording ledger for empty rows.
This commit is contained in:
Yun Chan 2026-09-28 20:45:52 +09:00
parent 2428ede03d
commit ba9ef9741e
161 changed files with 17056 additions and 2379 deletions

View file

@ -0,0 +1,224 @@
jest.mock('../src/lib/supabase', () => ({
supabase: { functions: { invoke: jest.fn() } },
}))
import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js'
import { supabase } from '../src/lib/supabase'
import {
edgeFailureField,
invokeEdgeFunction,
readEdgeFunctionHttpFailure,
type EdgeFunctionFailure,
} from '../src/lib/edge-functions'
import {
accountDeletionFailureMessageKey,
edgeAccountDeletionService,
runAccountDeletion,
type AccountDeletionService,
} from '../src/features/account/account-deletion-service'
const mockInvoke = (supabase as unknown as { functions: { invoke: jest.Mock } }).functions.invoke
function jsonResponse(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
/** Mirrors functions-js 2.103: non-2xx → { data: null, error: FunctionsHttpError(response) }. */
function httpFailure(body: unknown, status: number): { data: null; error: FunctionsHttpError } {
return { data: null, error: new FunctionsHttpError(jsonResponse(body, status)) }
}
beforeEach(() => {
mockInvoke.mockReset()
})
describe('invokeEdgeFunction adapter', () => {
test('decodes the JSON body of a 403 FunctionsHttpError instead of relying on data', async () => {
mockInvoke.mockResolvedValue(httpFailure({
error: 'Recent authentication is required',
code: 'REAUTHENTICATION_REQUIRED',
}, 403))
const result = await invokeEdgeFunction('account-delete', { confirmation: 'x' })
expect(mockInvoke).toHaveBeenCalledWith('account-delete', { body: { confirmation: 'x' } })
expect(result.ok).toBe(false)
const failure = result as EdgeFunctionFailure
expect(failure.status).toBe(403)
expect(failure.body).toEqual({
readable: true,
payload: { error: 'Recent authentication is required', code: 'REAUTHENTICATION_REQUIRED' },
})
expect(edgeFailureField(failure, 'code')).toBe('REAUTHENTICATION_REQUIRED')
expect(edgeFailureField(failure, 'error')).toBe('Recent authentication is required')
expect(failure.message).toBe('Edge Function returned a non-2xx status code')
})
test('reports an unreadable body without inventing a code', async () => {
mockInvoke.mockResolvedValue({
data: null,
error: new FunctionsHttpError(new Response('<html>bad gateway</html>', { status: 502 })),
})
const result = await invokeEdgeFunction('team-invite', {})
expect(result).toMatchObject({ ok: false, status: 502, body: { readable: false } })
expect(edgeFailureField(result as EdgeFunctionFailure, 'code')).toBeNull()
})
test('keeps fetch errors and thrown values as failures without an HTTP status', async () => {
mockInvoke.mockResolvedValueOnce({ data: null, error: new FunctionsFetchError('offline') })
await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({
ok: false,
status: null,
body: null,
})
const thrown = new TypeError('Network request failed')
mockInvoke.mockRejectedValueOnce(thrown)
await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({
ok: false,
status: null,
body: null,
message: 'Network request failed',
cause: thrown,
})
})
test('returns data on success', async () => {
mockInvoke.mockResolvedValue({ data: { success: true }, error: null })
await expect(invokeEdgeFunction('account-delete', {})).resolves.toEqual({
ok: true,
data: { success: true },
})
})
test('readEdgeFunctionHttpFailure ignores errors without a Response context', async () => {
await expect(readEdgeFunctionHttpFailure(new Error('x'))).resolves.toBeNull()
await expect(readEdgeFunctionHttpFailure({ context: 'nope' })).resolves.toBeNull()
await expect(readEdgeFunctionHttpFailure(null)).resolves.toBeNull()
})
})
describe('edgeAccountDeletionService', () => {
test('recognises REAUTHENTICATION_REQUIRED from a 403 response body', async () => {
mockInvoke.mockResolvedValue(httpFailure({
error: 'Recent authentication is required',
code: 'REAUTHENTICATION_REQUIRED',
}, 403))
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({
ok: false,
code: 'REAUTHENTICATION_REQUIRED',
message: 'Recent authentication is required',
})
expect(mockInvoke).toHaveBeenCalledWith('account-delete', {
body: { confirmation: 'DELETE_MY_ACCOUNT' },
})
})
test('recognises ACTIVE_SUBSCRIPTION from a 409 response body', async () => {
mockInvoke.mockResolvedValue(httpFailure({
error: 'Cancel the active subscription before deleting the account',
code: 'ACTIVE_SUBSCRIPTION',
}, 409))
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({
ok: false,
code: 'ACTIVE_SUBSCRIPTION',
})
})
test('maps a missing function (404) to SERVER_ENDPOINT_UNAVAILABLE', async () => {
mockInvoke.mockResolvedValue(httpFailure({ code: 'NOT_FOUND', message: 'Requested function was not found' }, 404))
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({
ok: false,
code: 'SERVER_ENDPOINT_UNAVAILABLE',
})
})
test('treats a 2xx without success=true as REQUEST_FAILED and succeeds only on success=true', async () => {
mockInvoke.mockResolvedValueOnce({ data: { success: false, error: 'nope' }, error: null })
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({
ok: false,
code: 'REQUEST_FAILED',
message: 'nope',
})
mockInvoke.mockResolvedValueOnce({ data: { success: true }, error: null })
await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ ok: true })
})
})
describe('accountDeletionFailureMessageKey', () => {
test('always maps to translated copy, never raw server/SDK text', () => {
expect(accountDeletionFailureMessageKey('REAUTHENTICATION_REQUIRED'))
.toBe('mobile.account.reauthenticationRequired')
expect(accountDeletionFailureMessageKey('SERVER_ENDPOINT_UNAVAILABLE'))
.toBe('mobile.account.deleteUnavailable')
expect(accountDeletionFailureMessageKey('ACTIVE_SUBSCRIPTION')).toBe('mobile.account.deleteFailed')
expect(accountDeletionFailureMessageKey('REQUEST_FAILED')).toBe('mobile.account.deleteFailed')
})
})
describe('runAccountDeletion ordering', () => {
function service(result: Awaited<ReturnType<AccountDeletionService['deleteCurrentAccount']>>): AccountDeletionService {
return { deleteCurrentAccount: jest.fn(async () => result) }
}
test('a refused deletion leaves push registration and the local session untouched', async () => {
const detachPushRegistration = jest.fn(async () => undefined)
const purgeLocalSession = jest.fn(async () => undefined)
await expect(runAccountDeletion({
deletionService: service({ ok: false, code: 'REAUTHENTICATION_REQUIRED' }),
detachPushRegistration,
purgeLocalSession,
})).resolves.toEqual({ ok: false, code: 'REAUTHENTICATION_REQUIRED' })
expect(detachPushRegistration).not.toHaveBeenCalled()
expect(purgeLocalSession).not.toHaveBeenCalled()
})
test('detaches push after server confirmation and before the local purge', async () => {
const calls: string[] = []
const deletionService: AccountDeletionService = {
deleteCurrentAccount: jest.fn(async () => {
calls.push('delete')
return { ok: true } as const
}),
}
await expect(runAccountDeletion({
deletionService,
detachPushRegistration: async () => { calls.push('detach') },
purgeLocalSession: async () => { calls.push('purge') },
})).resolves.toEqual({ ok: true })
expect(calls).toEqual(['delete', 'detach', 'purge'])
})
test('a push detach failure after deletion does not block the local purge', async () => {
const purgeLocalSession = jest.fn(async () => undefined)
await expect(runAccountDeletion({
deletionService: service({ ok: true }),
detachPushRegistration: async () => { throw new Error('both push boundaries failed') },
purgeLocalSession,
})).resolves.toEqual({ ok: true })
expect(purgeLocalSession).toHaveBeenCalledTimes(1)
})
test('a local purge failure propagates to the caller', async () => {
await expect(runAccountDeletion({
deletionService: service({ ok: true }),
detachPushRegistration: async () => undefined,
purgeLocalSession: async () => { throw new Error('purge failed') },
})).rejects.toThrow('purge failed')
})
})

View file

@ -0,0 +1,214 @@
const mockClearHistory = jest.fn()
const mockClearEntitlements = jest.fn()
const mockClearPreferences = jest.fn()
const mockCancelRecorder = jest.fn()
const mockAcquireRecorder = jest.fn()
const mockClearQueuedAudio = jest.fn()
const mockRetainQueuedAudio = jest.fn()
const mockClearActions = jest.fn()
const mockClearGenerationKeys = jest.fn()
const mockDeletePush = jest.fn()
jest.mock('../src/features/history/history-cache', () => ({
clearAllHistoryCaches: () => mockClearHistory(),
}))
jest.mock('../src/lib/entitlement-context', () => ({
clearAllEntitlementCaches: () => mockClearEntitlements(),
}))
jest.mock('../src/lib/preferences-context', () => ({
clearAllUserPreferenceCaches: () => mockClearPreferences(),
}))
jest.mock('../src/lib/audio-recorder', () => ({
audioRecorder: {
cancel: () => mockCancelRecorder(),
acquire: (owner: string) => mockAcquireRecorder(owner),
},
}))
jest.mock('../src/features/recording/durable-processing-queue', () => ({
clearAllQueuedAudio: () => mockClearQueuedAudio(),
retainQueuedAudioOnlyForUser: (userId: string) => mockRetainQueuedAudio(userId),
}))
jest.mock('../src/features/actions/action-service', () => ({
clearAllActionHistories: () => mockClearActions(),
}))
jest.mock('../src/features/templates', () => ({
clearEveryGenerationIdempotencyKey: () => mockClearGenerationKeys(),
}))
jest.mock('../src/features/notifications/notification-native', () => ({
deleteNativePushRegistration: () => mockDeletePush(),
}))
import AsyncStorage from '@react-native-async-storage/async-storage'
import { purgeAllAccountLocalData } from '../src/lib/account-local-data'
import { DISCARD_UNSYNCED_WORK } from '../src/lib/auth-transition-policy'
import { RecorderBusyError } from '../src/lib/recorder/recorder-errors'
import type {
RecorderSession,
RecordingRuntimeSnapshot,
} from '../src/lib/recorder/recorder-types'
import { stopLiveCaptureKeepingFile } from '../src/lib/retain-live-capture'
import {
createRetainedWorkOwnerStore,
retainedAccountWork,
retainedAccountWorkTestContract,
} from '../src/lib/retained-account-work'
const USER_A = '11111111-1111-4111-8111-111111111111'
const cacheOperations = [
mockClearHistory,
mockClearEntitlements,
mockClearPreferences,
mockClearActions,
mockClearGenerationKeys,
mockDeletePush,
]
function snapshot(state: RecordingRuntimeSnapshot['state'], withFile: boolean): RecordingRuntimeSnapshot {
return {
state,
recording: withFile
? { uri: 'file:///c.wav', path: '/c.wav', fileName: 'c.wav', mimeType: 'audio/wav', size: 10, durationMs: 90 * 60_000 }
: null,
meetingId: null,
interruptionReason: null,
startedAtMs: 0,
}
}
function fakeSession(overrides: Partial<RecorderSession> = {}): jest.Mocked<RecorderSession> {
return {
owner: 'record',
start: jest.fn(async () => undefined),
pause: jest.fn(async () => undefined),
resume: jest.fn(async () => undefined),
stop: jest.fn(async () => snapshot('stopped', true).recording!),
cleanup: jest.fn(async () => undefined),
cancel: jest.fn(async () => undefined),
restore: jest.fn(async () => snapshot('stopped', true)),
...overrides,
} as jest.Mocked<RecorderSession>
}
describe('account purge keeps unsynced work on involuntary sign-out (redteam r3-16 #2)', () => {
beforeEach(async () => {
await AsyncStorage.clear()
await retainedAccountWork.release()
for (const operation of [...cacheOperations, mockCancelRecorder, mockClearQueuedAudio, mockRetainQueuedAudio]) {
operation.mockReset().mockResolvedValue(undefined)
}
mockAcquireRecorder.mockReset().mockResolvedValue(fakeSession())
})
it('purges caches but keeps the capture and the owner queue', async () => {
await purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A })
for (const operation of cacheOperations) expect(operation).toHaveBeenCalledTimes(1)
expect(mockCancelRecorder).not.toHaveBeenCalled()
expect(mockClearQueuedAudio).not.toHaveBeenCalled()
expect(mockRetainQueuedAudio).toHaveBeenCalledWith(USER_A)
expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBe(USER_A)
})
it('discards everything and releases the retention marker on a discard boundary', async () => {
await retainedAccountWork.retain(USER_A)
await purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK)
expect(mockCancelRecorder).toHaveBeenCalledTimes(1)
expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1)
expect(retainedAccountWork.current()).toBeNull()
expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBeNull()
})
it('runs a discard requested during a retain run instead of sharing it', async () => {
let finishRetain: (() => void) | null = null
mockRetainQueuedAudio.mockReturnValueOnce(new Promise<void>((resolve) => {
finishRetain = resolve
}))
const retain = purgeAllAccountLocalData({ kind: 'retain', ownerUserId: USER_A })
const discard = purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK)
expect(discard).not.toBe(retain)
finishRetain?.()
await Promise.all([retain, discard])
expect(mockClearQueuedAudio).toHaveBeenCalledTimes(1)
expect(mockCancelRecorder).toHaveBeenCalledTimes(1)
expect(retainedAccountWork.current()).toBeNull()
})
})
describe('stopLiveCaptureKeepingFile (redteam r3-16 #2)', () => {
it('stops an in-process capture and keeps its reattachable file', async () => {
const session = fakeSession()
await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) })
expect(session.stop).toHaveBeenCalledTimes(1)
expect(session.cancel).not.toHaveBeenCalled()
expect(session.cleanup).not.toHaveBeenCalled()
})
it('reattaches a capture that outlived a JS restart before stopping it', async () => {
const session = fakeSession({
stop: jest
.fn()
.mockRejectedValueOnce(new Error('Cannot stop recorder while it is idle'))
.mockResolvedValueOnce(snapshot('stopped', true).recording),
restore: jest
.fn()
.mockResolvedValueOnce(snapshot('recording', false))
.mockResolvedValueOnce(snapshot('stopped', true)),
})
await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) })
expect(session.stop).toHaveBeenCalledTimes(2)
expect(session.cancel).not.toHaveBeenCalled()
})
it('falls back to discarding a capture the backend cannot hand back', async () => {
const session = fakeSession({ restore: jest.fn(async () => snapshot('idle', false)) })
await stopLiveCaptureKeepingFile({ acquire: jest.fn(async () => session) })
expect(session.cancel).toHaveBeenCalledTimes(1)
})
it('discards a transient Talk capture through its own owner session', async () => {
const talk = fakeSession({ owner: 'talk' })
const acquire = jest.fn(async (owner: string) => {
if (owner === 'record') throw new RecorderBusyError('talk')
return talk
})
await stopLiveCaptureKeepingFile({ acquire })
expect(acquire).toHaveBeenLastCalledWith('talk')
expect(talk.cancel).toHaveBeenCalledTimes(1)
expect(talk.stop).not.toHaveBeenCalled()
})
})
describe('retained work owner store (redteam r3-16 #2)', () => {
it('persists the owner across a restart and ignores a stale load', async () => {
const values = new Map<string, string>()
let resolveRead: ((value: string | null) => void) | null = null
const storage = {
getItem: jest.fn((key: string) => {
const valueAtRead = values.get(key) ?? null
return new Promise<string | null>((resolve) => {
resolveRead = () => resolve(valueAtRead)
})
}),
setItem: jest.fn(async (key: string, value: string) => { values.set(key, value) }),
removeItem: jest.fn(async (key: string) => { values.delete(key) }),
}
const first = createRetainedWorkOwnerStore(storage)
await first.retain('owner-a')
const restarted = createRetainedWorkOwnerStore(storage)
const loading = restarted.load()
resolveRead?.(null)
await expect(loading).resolves.toBe('owner-a')
expect(restarted.current()).toBe('owner-a')
const staleLoad = createRetainedWorkOwnerStore(storage)
const pending = staleLoad.load()
await staleLoad.release()
resolveRead?.(null)
await expect(pending).resolves.toBeNull()
expect(staleLoad.current()).toBeNull()
})
})

View file

@ -102,19 +102,16 @@ describe('mobile auth redirect boundary', () => {
expect(exchangeCodeForSession).toHaveBeenCalledTimes(2)
})
it('accepts a complete legacy token pair once and rejects partial credentials', async () => {
// redteam r3-16 #1: 토큰 쌍 콜백(implicit flow)은 로그인 CSRF 경로라 완전한 쌍이어도 세션으로 바꾸지 않는다.
// 이전 계약("완전한 레거시 쌍은 한 번 받아들인다")을 대체한다 — auth-redteam-r3-16.test.tsx 와 같은 계약.
it('rejects token-pair callbacks, complete or partial, without touching the session', async () => {
const { operations, setSession } = createOperations()
const complete = createAuthRedirectHandler(operations)
const url = 'd3ro-voice://auth-callback#access_token=access&refresh_token=refresh&type=recovery'
await expect(complete(url)).resolves.toBe('recovery')
await expect(complete(url)).resolves.toBe('recovery')
expect(setSession).toHaveBeenCalledTimes(1)
expect(setSession).toHaveBeenCalledWith({
access_token: 'access',
refresh_token: 'refresh',
})
await expect(complete('d3ro-voice://auth-callback#access_token=access&refresh_token=refresh&type=recovery'))
.rejects.toMatchObject({ code: 'invalid_callback' })
await expect(complete('d3ro-voice://auth-callback#access_token=partial'))
.rejects.toMatchObject({ code: 'invalid_callback' })
expect(setSession).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,298 @@
import React from 'react'
import { Linking } from 'react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import type { AuthChangeEvent, Session } from '@supabase/supabase-js'
const mockGetSession = jest.fn()
const mockOnAuthStateChange = jest.fn()
const mockStopAutoRefresh = jest.fn()
const mockStartAutoRefresh = jest.fn()
const mockSetSession = jest.fn()
const mockExchangeCodeForSession = jest.fn()
const mockPurgeAccountLocalData = jest.fn()
const mockClearSecureAuthStorage = jest.fn()
jest.mock('../src/lib/supabase', () => ({
isSupabaseConfigured: () => true,
supabase: {
auth: {
getSession: (...args: unknown[]) => mockGetSession(...args),
onAuthStateChange: (...args: unknown[]) => mockOnAuthStateChange(...args),
stopAutoRefresh: (...args: unknown[]) => mockStopAutoRefresh(...args),
startAutoRefresh: (...args: unknown[]) => mockStartAutoRefresh(...args),
setSession: (...args: unknown[]) => mockSetSession(...args),
exchangeCodeForSession: (...args: unknown[]) => mockExchangeCodeForSession(...args),
},
},
}))
jest.mock('../src/lib/account-local-data', () => ({
purgeAllAccountLocalData: (...args: unknown[]) => mockPurgeAccountLocalData(...args),
}))
jest.mock('../src/lib/secure-auth-storage', () => ({
clearAllSecureAuthStorage: (...args: unknown[]) => mockClearSecureAuthStorage(...args),
}))
import AsyncStorage from '@react-native-async-storage/async-storage'
import { AuthProvider, useAuth } from '../src/lib/auth-context'
import { createAuthRedirectHandler } from '../src/lib/auth-redirect'
import {
DISCARD_UNSYNCED_WORK,
planAuthTransition,
type AuthTransitionFacts,
} from '../src/lib/auth-transition-policy'
import {
retainedAccountWork,
retainedAccountWorkTestContract,
} from '../src/lib/retained-account-work'
type AuthSnapshot = ReturnType<typeof useAuth>
type AuthCallback = (event: AuthChangeEvent, session: Session | null) => void
type UrlListener = (event: { url: string }) => void
const ATTACKER_LINK = 'd3ro-voice://auth-callback#access_token=attacker-access&refresh_token=attacker-refresh'
let latest: AuthSnapshot
let authCallback: AuthCallback | null = null
let urlListener: UrlListener | null = null
let renderer: ReactTestRenderer | null = null
let storedSession: Session | null = null
function session(userId: string): Session {
return {
access_token: `access-${userId}`,
token_type: 'bearer',
expires_in: 3600,
expires_at: 4_000_000_000,
refresh_token: `refresh-${userId}`,
user: { id: userId },
} as unknown as Session
}
function Probe(): null {
latest = useAuth()
return null
}
async function flush(): Promise<void> {
await act(async () => {
for (let index = 0; index < 12; index += 1) await Promise.resolve()
})
}
async function mount(restoredSession: Session | null): Promise<void> {
storedSession = restoredSession
await act(async () => {
renderer = create(<AuthProvider><Probe /></AuthProvider>)
})
await flush()
}
function emit(event: AuthChangeEvent, nextSession: Session | null): void {
storedSession = nextSession
if (authCallback === null) throw new Error('auth callback is not subscribed')
authCallback(event, nextSession)
}
function facts(overrides: Partial<AuthTransitionFacts>): AuthTransitionFacts {
return {
previousUserId: null,
nextUserId: null,
retainedOwnerUserId: null,
forcePurge: false,
cleanupPending: false,
explicit: false,
...overrides,
}
}
describe('mobile login CSRF via implicit token callback (redteam r3-16 #1)', () => {
it('never turns a token-pair callback into a session', async () => {
const setSession = jest.fn(async () => ({ data: { session: null, user: null }, error: null }))
const exchangeCodeForSession = jest.fn()
const complete = createAuthRedirectHandler({
exchangeCodeForSession,
setSession,
} as unknown as Parameters<typeof createAuthRedirectHandler>[0])
await expect(complete(ATTACKER_LINK)).rejects.toMatchObject({ code: 'invalid_callback' })
await expect(complete(`${ATTACKER_LINK}&type=recovery`))
.rejects.toMatchObject({ code: 'invalid_callback' })
await expect(complete('d3ro-voice://auth-callback?code=c#access_token=a&refresh_token=r'))
.rejects.toMatchObject({ code: 'invalid_callback' })
expect(setSession).not.toHaveBeenCalled()
expect(exchangeCodeForSession).not.toHaveBeenCalled()
})
it('keeps the signed-in victim and their local data when the link is opened', async () => {
authCallback = null
urlListener = null
mockGetSession.mockReset().mockImplementation(async () => ({
data: { session: storedSession },
error: null,
}))
mockOnAuthStateChange.mockReset().mockImplementation((callback: AuthCallback) => {
authCallback = callback
return { data: { subscription: { unsubscribe: jest.fn() } } }
})
mockSetSession.mockReset().mockResolvedValue({ data: { session: null, user: null }, error: null })
mockPurgeAccountLocalData.mockReset().mockResolvedValue(undefined)
jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null)
jest.spyOn(Linking, 'addEventListener').mockImplementation(((_type: string, listener: UrlListener) => {
urlListener = listener
return { remove: jest.fn() }
}) as unknown as typeof Linking.addEventListener)
await mount(session('victim'))
expect(latest.user?.id).toBe('victim')
await act(async () => {
urlListener?.({ url: ATTACKER_LINK })
})
await flush()
expect(mockSetSession).not.toHaveBeenCalled()
expect(mockPurgeAccountLocalData).not.toHaveBeenCalled()
expect(latest.user?.id).toBe('victim')
expect(latest.authError).toBe('callback_failed')
act(() => renderer?.unmount())
renderer = null
jest.restoreAllMocks()
})
})
describe('auth transition policy (redteam r3-16 #2)', () => {
it('retains the owner work on involuntary session loss', () => {
expect(planAuthTransition(facts({ previousUserId: 'a' }))).toEqual({
purge: true,
unsyncedWork: { kind: 'retain', ownerUserId: 'a' },
})
})
it('discards on explicit logout and on a different account', () => {
expect(planAuthTransition(facts({ previousUserId: 'a', explicit: true, forcePurge: true })))
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
expect(planAuthTransition(facts({ previousUserId: 'a', nextUserId: 'b' })))
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', nextUserId: 'b' })))
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
})
it('lets the owner come back without a purge and releases the marker', () => {
expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', nextUserId: 'a' })))
.toEqual({ purge: false, releaseRetainedWork: true })
expect(planAuthTransition(facts({ previousUserId: 'a', nextUserId: 'a' })))
.toEqual({ purge: false, releaseRetainedWork: false })
})
it('keeps retained work across a cold boot without a session, discards unowned work', () => {
expect(planAuthTransition(facts({ retainedOwnerUserId: 'a', forcePurge: true })))
.toEqual({ purge: true, unsyncedWork: { kind: 'retain', ownerUserId: 'a' } })
expect(planAuthTransition(facts({ forcePurge: true })))
.toEqual({ purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK })
})
})
describe('AuthProvider keeps unsynced recordings on involuntary sign-out (redteam r3-16 #2)', () => {
beforeEach(async () => {
authCallback = null
renderer = null
storedSession = null
await AsyncStorage.clear()
await retainedAccountWork.release()
mockGetSession.mockReset().mockImplementation(async () => ({
data: { session: storedSession },
error: null,
}))
mockOnAuthStateChange.mockReset().mockImplementation((callback: AuthCallback) => {
authCallback = callback
return { data: { subscription: { unsubscribe: jest.fn() } } }
})
mockStopAutoRefresh.mockReset().mockResolvedValue(undefined)
mockStartAutoRefresh.mockReset().mockResolvedValue(undefined)
mockClearSecureAuthStorage.mockReset().mockResolvedValue(undefined)
// The real purge records the retained owner; mirror that contract here.
mockPurgeAccountLocalData.mockReset().mockImplementation(async (disposition?: {
kind: 'discard' | 'retain'
ownerUserId?: string
}) => {
if (disposition?.kind === 'retain' && disposition.ownerUserId !== undefined) {
await retainedAccountWork.retain(disposition.ownerUserId)
} else {
await retainedAccountWork.release()
}
})
jest.spyOn(Linking, 'getInitialURL').mockResolvedValue(null)
})
afterEach(() => {
if (renderer !== null) act(() => renderer?.unmount())
jest.restoreAllMocks()
})
it('retains A work on SIGNED_OUT and resumes it without a purge when A returns', async () => {
await mount(session('user-a'))
act(() => emit('SIGNED_OUT', null))
await flush()
expect(mockPurgeAccountLocalData).toHaveBeenCalledTimes(1)
expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith({ kind: 'retain', ownerUserId: 'user-a' })
expect(mockClearSecureAuthStorage).toHaveBeenCalledTimes(1)
expect(latest.user).toBeNull()
expect(await AsyncStorage.getItem(retainedAccountWorkTestContract.storageKey)).toBe('user-a')
act(() => emit('SIGNED_IN', session('user-a')))
await flush()
expect(mockPurgeAccountLocalData).toHaveBeenCalledTimes(1)
expect(latest.user?.id).toBe('user-a')
expect(retainedAccountWork.current()).toBeNull()
})
it('discards retained work before a different account is committed', async () => {
await mount(session('user-a'))
act(() => emit('SIGNED_OUT', null))
await flush()
let finishDiscard: (() => void) | null = null
mockPurgeAccountLocalData.mockImplementationOnce(() => new Promise<void>((resolve) => {
finishDiscard = resolve
}))
act(() => emit('SIGNED_IN', session('user-b')))
await flush()
expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith(DISCARD_UNSYNCED_WORK)
expect(latest.user).toBeNull()
finishDiscard?.()
await flush()
expect(latest.user?.id).toBe('user-b')
})
it('keeps retained work across a cold boot with no session', async () => {
await retainedAccountWork.retain('user-a')
await mount(null)
expect(mockPurgeAccountLocalData).toHaveBeenCalledWith({ kind: 'retain', ownerUserId: 'user-a' })
})
it('still discards everything on an explicit logout', async () => {
await mount(session('user-a'))
await act(async () => {
await latest.purgeLocalSession()
})
expect(mockPurgeAccountLocalData).toHaveBeenLastCalledWith(DISCARD_UNSYNCED_WORK)
})
it('discards retained work when a racing newer session belongs to another account', async () => {
await mount(session('user-a'))
storedSession = session('user-b')
act(() => {
authCallback?.('SIGNED_OUT', null)
})
await flush()
expect(mockPurgeAccountLocalData.mock.calls.map(([disposition]) => disposition)).toEqual([
{ kind: 'retain', ownerUserId: 'user-a' },
DISCARD_UNSYNCED_WORK,
])
expect(latest.user?.id).toBe('user-b')
expect(mockClearSecureAuthStorage).not.toHaveBeenCalled()
})
})

View file

@ -0,0 +1,139 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { FileSystem } from 'react-native-file-access'
import type {
AudioPipelineResult,
LocalAudioInput,
} from '../src/features/import/audio-import-types'
const mockProcessAudioInput = jest.fn<Promise<AudioPipelineResult>, [LocalAudioInput, unknown]>()
jest.mock('../src/features/import/audio-transcription-service', () => ({
processAudioInput: (input: LocalAudioInput, options: unknown) => mockProcessAudioInput(input, options),
}))
jest.mock('../src/features/meetings/meetings-service', () => ({
failMeetingRecording: jest.fn(async () => undefined),
markMeetingProcessingFailure: jest.fn(async () => undefined),
queueMeetingRecording: jest.fn(async () => undefined),
}))
import {
clearQueuedAudioForUser,
durableQueueTestContract,
resumeQueuedAudioForUser,
retainQueuedAudioOnlyForUser,
type DurableQueueItem,
} from '../src/features/recording/durable-processing-queue'
const START_MS = 1_700_000_000_000
const USER_A = '11111111-1111-4111-8111-111111111111'
const USER_B = '22222222-2222-4222-8222-222222222222'
const mockFileSystem = FileSystem as typeof FileSystem & { filesystem: Map<string, string> }
function queued(
userId: string,
suffix: string,
overrides: Partial<DurableQueueItem> = {},
): DurableQueueItem {
const path = `${durableQueueTestContract.queueDirectory}/queued-${suffix}.wav`
return {
schemaVersion: 1,
id: suffix,
userId,
meetingId: null,
path,
uri: `file://${path}`,
fileName: `${suffix}.wav`,
mimeType: 'audio/wav',
sizeBytes: 5,
durationMs: 100,
source: 'recording',
languageCode: 'ko',
status: 'retry',
phase: null,
attempts: 1,
uploadedBytes: 0,
nextAttemptAtMs: START_MS + 5_000,
lastErrorCode: 'upload',
lastErrorMessage: null,
createdAtMs: 1,
updatedAtMs: 1,
...overrides,
}
}
function result(): AudioPipelineResult {
return {
historyId: null,
audioFileId: 'audio-file',
meetingId: null,
processingJobId: null,
transcript: 'ok',
provider: 'test',
language: 'ko',
durationSeconds: 1,
deduplicated: false,
}
}
async function seed(items: DurableQueueItem[]): Promise<void> {
for (const item of items) mockFileSystem.filesystem.set(item.path, 'audio')
await AsyncStorage.setItem(durableQueueTestContract.storageKey, JSON.stringify(items))
}
async function stored(): Promise<DurableQueueItem[]> {
return durableQueueTestContract.parseQueue(
await AsyncStorage.getItem(durableQueueTestContract.storageKey),
)
}
async function settle(): Promise<void> {
for (let index = 0; index < 5; index += 1) await jest.advanceTimersByTimeAsync(0)
}
describe('durable queue keeps the signed-out owner recordings (redteam r3-16 #2)', () => {
beforeEach(async () => {
jest.useFakeTimers({ now: START_MS })
await AsyncStorage.clear()
mockFileSystem.filesystem.clear()
mockProcessAudioInput.mockReset().mockResolvedValue(result())
})
afterEach(async () => {
for (const userId of [USER_A, USER_B]) {
await clearQueuedAudioForUser(userId).catch(() => undefined)
}
await settle()
jest.useRealTimers()
})
test('keeps the owner items and files, discards other accounts', async () => {
const ownerRetry = queued(USER_A, 'aaa001')
const ownerPending = queued(USER_A, 'aaa002', { status: 'pending', attempts: 0, nextAttemptAtMs: START_MS })
const other = queued(USER_B, 'bbb001')
await seed([ownerRetry, ownerPending, other])
await retainQueuedAudioOnlyForUser(USER_A)
expect((await stored()).map(item => item.id).sort()).toEqual(['aaa001', 'aaa002'])
expect(mockFileSystem.filesystem.has(ownerRetry.path)).toBe(true)
expect(mockFileSystem.filesystem.has(ownerPending.path)).toBe(true)
expect(mockFileSystem.filesystem.has(other.path)).toBe(false)
})
test('does not spend retries while the owner is signed out, resumes on sign-in', async () => {
const item = queued(USER_A, 'aaa003', { nextAttemptAtMs: START_MS })
await seed([item])
await retainQueuedAudioOnlyForUser(USER_A)
await jest.advanceTimersByTimeAsync(60 * 60_000)
await settle()
expect(mockProcessAudioInput).not.toHaveBeenCalled()
expect((await stored())[0]?.attempts).toBe(1)
await resumeQueuedAudioForUser(USER_A)
await settle()
expect(mockProcessAudioInput).toHaveBeenCalledTimes(1)
expect(await stored()).toEqual([])
})
})

View file

@ -0,0 +1,166 @@
const mockRealtimeOn = jest.fn()
const mockRealtimeSubscribe = jest.fn()
const mockRemoveChannel = jest.fn(async () => 'ok')
const mockRealtimeChannel: Record<string, unknown> = {
on: mockRealtimeOn,
subscribe: mockRealtimeSubscribe,
}
mockRealtimeOn.mockReturnValue(mockRealtimeChannel)
mockRealtimeSubscribe.mockReturnValue(mockRealtimeChannel)
jest.mock('../src/lib/supabase', () => ({
supabase: {
from: jest.fn(),
channel: jest.fn(() => mockRealtimeChannel),
removeChannel: (...args: unknown[]) => mockRemoveChannel(...(args as [])),
},
}))
import {
type ForegroundStatePort,
type IntervalPort,
KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS,
knowledgeRealtimeBindings,
startForegroundReconciliation,
} from '../src/features/knowledge/knowledge-realtime'
import { subscribeToKnowledgeDocuments } from '../src/features/knowledge/knowledge-service'
const USER_A = '11111111-1111-4111-8111-111111111111'
interface FakeForeground extends ForegroundStatePort {
set: (active: boolean) => void
listenerCount: () => number
}
function fakeForeground(initial: boolean): FakeForeground {
let active = initial
const listeners = new Set<(active: boolean) => void>()
return {
isActive: () => active,
onChange: (listener) => {
listeners.add(listener)
return { remove: () => { listeners.delete(listener) } }
},
set: (next) => {
active = next
for (const listener of listeners) listener(next)
},
listenerCount: () => listeners.size,
}
}
interface FakeInterval extends IntervalPort {
tick: () => void
cleared: () => boolean
lastMs: () => number | null
}
function fakeInterval(): FakeInterval {
let callback: (() => void) | null = null
let cleared = false
let lastMs: number | null = null
const handle = 1 as unknown as ReturnType<typeof setInterval>
return {
set: (cb, ms) => { callback = cb; lastMs = ms; return handle },
clear: () => { cleared = true; callback = null },
tick: () => { callback?.() },
cleared: () => cleared,
lastMs: () => lastMs,
}
}
function subscribedBindings(): Array<{ event: string, filter?: string, table: string }> {
return mockRealtimeOn.mock.calls.map((call) => call[1] as {
event: string
filter?: string
table: string
})
}
describe('knowledge realtime policy (redteam r3-20)', () => {
beforeEach(() => {
mockRealtimeOn.mockClear()
mockRealtimeSubscribe.mockClear()
mockRemoveChannel.mockClear()
})
it('never subscribes to unfiltered DELETE events on knowledge_documents', () => {
const subscription = subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), {
reconciliation: { foreground: fakeForeground(true), interval: fakeInterval() },
})
const events = subscribedBindings().map((binding) => binding.event)
expect(events).not.toContain('DELETE')
expect(events).not.toContain('*')
expect(events).toEqual(['INSERT', 'UPDATE'])
void subscription.unsubscribe()
})
it('narrows INSERT/UPDATE to the owner when a user id is supplied', () => {
const subscription = subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), {
userId: USER_A,
reconciliation: { foreground: fakeForeground(true), interval: fakeInterval() },
})
expect(subscribedBindings()).toEqual([
expect.objectContaining({ event: 'INSERT', filter: `user_id=eq.${USER_A}` }),
expect.objectContaining({ event: 'UPDATE', filter: `user_id=eq.${USER_A}` }),
])
void subscription.unsubscribe()
})
it('rejects a malformed owner id instead of interpolating it into the filter', () => {
expect(() => subscribeToKnowledgeDocuments(jest.fn(), jest.fn(), {
userId: 'x,user_id=neq.0',
})).toThrow()
expect(mockRealtimeOn).not.toHaveBeenCalled()
})
it('reconciles deletions by foreground polling and stops on unsubscribe', async () => {
const onChanged = jest.fn()
const foreground = fakeForeground(true)
const interval = fakeInterval()
const subscription = subscribeToKnowledgeDocuments(onChanged, jest.fn(), {
reconciliation: { foreground, interval },
})
expect(interval.lastMs()).toBe(KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS)
interval.tick()
expect(onChanged).toHaveBeenCalledTimes(1)
await subscription.unsubscribe()
expect(interval.cleared()).toBe(true)
expect(foreground.listenerCount()).toBe(0)
expect(mockRemoveChannel).toHaveBeenCalledWith(mockRealtimeChannel)
interval.tick()
foreground.set(false)
foreground.set(true)
expect(onChanged).toHaveBeenCalledTimes(1)
})
it('pauses polling in the background and reconciles once on resume', () => {
const onReconcile = jest.fn()
const foreground = fakeForeground(true)
const interval = fakeInterval()
const reconciliation = startForegroundReconciliation(onReconcile, { foreground, interval })
foreground.set(false)
interval.tick()
expect(onReconcile).not.toHaveBeenCalled()
foreground.set(true)
expect(onReconcile).toHaveBeenCalledTimes(1)
foreground.set(true)
expect(onReconcile).toHaveBeenCalledTimes(1)
interval.tick()
expect(onReconcile).toHaveBeenCalledTimes(2)
reconciliation.stop()
reconciliation.stop()
})
it('builds bindings without DELETE and without a filter when no owner is given', () => {
expect(knowledgeRealtimeBindings()).toEqual([
{ event: 'INSERT', schema: 'public', table: 'knowledge_documents' },
{ event: 'UPDATE', schema: 'public', table: 'knowledge_documents' },
])
})
})

View file

@ -235,9 +235,11 @@ describe('knowledge edge contracts', () => {
})).rejects.toMatchObject({ code: 'invalid-response' })
})
it('subscribes to document inserts, updates, and deletes through RLS realtime', () => {
// redteam r3-20: 필터가 걸린 postgres_changes 는 DELETE 를 보내지 않고, 필터 없는 DELETE 구독은 남의 행 id 를 흘린다.
// 삭제는 knowledge-realtime 의 다른 경로로 받는다 — knowledge-realtime-redteam-r3-20.test.ts 와 같은 계약.
it('subscribes to document inserts and updates through RLS realtime, never to DELETE', () => {
subscribeToKnowledgeDocuments(jest.fn(), jest.fn())
const events = mockRealtimeOn.mock.calls.map((call) => (call[1] as { event: string }).event)
expect(events).toEqual(['INSERT', 'UPDATE', 'DELETE'])
expect(events).toEqual(['INSERT', 'UPDATE'])
})
})

View file

@ -0,0 +1,142 @@
jest.mock('../src/lib/supabase', () => ({
supabase: {
rpc: jest.fn(),
},
}));
import type { Meeting, MeetingStatus } from '@d3ro/api-client';
import { supabase } from '../src/lib/supabase';
import {
beginMeetingRecording,
cancelMeetingRecording,
completeMeetingProcessing,
failMeetingRecording,
queueMeetingRecording,
} from '../src/features/meetings/meetings-service';
import {
confirmsMeetingTransition,
expectedMeetingStates,
} from '../src/features/meetings/meeting-recording-state-policy';
const USER_ID = '11111111-1111-4111-8111-111111111111';
const OTHER_USER_ID = '55555555-5555-4555-8555-555555555555';
const MEETING_ID = '22222222-2222-4222-8222-222222222222';
const AUDIO_ID = '33333333-3333-4333-8333-333333333333';
const IDEMPOTENCY = `mobile-meeting:${MEETING_ID}:${'a'.repeat(64)}`;
const mockRpc = supabase.rpc as jest.Mock;
function meeting(status: MeetingStatus, userId = USER_ID): Meeting {
return {
id: MEETING_ID,
user_id: userId,
team_id: null,
title: 'Re-recorded meeting',
status,
started_at: '2026-08-21T00:00:00.000Z',
ended_at: '2026-08-21T00:01:00.000Z',
duration_ms: 60_000,
raw_transcript: 'earlier transcript',
edited_transcript: null,
minutes_markdown: 'earlier minutes',
minutes_json: null,
stt_model: 'whisper',
llm_model: null,
stt_latency_ms: 100,
llm_latency_ms: null,
error_message: null,
audio_storage_key: `${USER_ID}/imports/earlier.wav`,
created_at: '2026-08-21T00:00:00.000Z',
updated_at: '2026-08-21T00:01:00.000Z',
};
}
describe('re-record failure paths (server restores a meeting with content to completed)', () => {
beforeEach(() => mockRpc.mockReset());
test('cancelMeetingRecording accepts the restored completed meeting', async () => {
mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null });
await expect(cancelMeetingRecording(USER_ID, MEETING_ID))
.resolves.toMatchObject({ status: 'completed', raw_transcript: 'earlier transcript' });
expect(mockRpc).toHaveBeenCalledWith('mobile_cancel_meeting_recording', { p_meeting_id: MEETING_ID });
});
test('failMeetingRecording accepts the restored completed meeting', async () => {
mockRpc.mockResolvedValueOnce({ data: meeting('completed'), error: null });
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'Queued audio processing was cancelled'))
.resolves.toMatchObject({ status: 'completed' });
expect(mockRpc).toHaveBeenCalledWith('mobile_fail_meeting_recording', {
p_meeting_id: MEETING_ID,
p_error_message: 'Queued audio processing was cancelled',
});
});
test('a first recording still confirms the error outcome', async () => {
mockRpc
.mockResolvedValueOnce({ data: meeting('error'), error: null })
.mockResolvedValueOnce({ data: meeting('error'), error: null });
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).resolves.toMatchObject({ status: 'error' });
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).resolves.toMatchObject({ status: 'error' });
});
test.each<MeetingStatus>(['recording', 'processing'])(
'cancel/fail still reject a meeting left in %s',
async (status) => {
mockRpc
.mockResolvedValueOnce({ data: meeting(status), error: null })
.mockResolvedValueOnce({ data: meeting(status), error: null });
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({
code: 'server',
message: 'Meeting cancellation was not confirmed',
});
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({
code: 'server',
message: 'Meeting error state was not confirmed',
});
},
);
test('cancel/fail still reject a row owned by another user', async () => {
mockRpc
.mockResolvedValueOnce({ data: meeting('completed', OTHER_USER_ID), error: null })
.mockResolvedValueOnce({ data: meeting('error', OTHER_USER_ID), error: null });
await expect(cancelMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({ code: 'server' });
await expect(failMeetingRecording(USER_ID, MEETING_ID, 'failed')).rejects.toMatchObject({ code: 'server' });
});
test('the other transitions keep their single confirmed state', async () => {
mockRpc
.mockResolvedValueOnce({ data: meeting('completed'), error: null })
.mockResolvedValueOnce({ data: meeting('completed'), error: null })
.mockResolvedValueOnce({ data: meeting('error'), error: null });
await expect(beginMeetingRecording(USER_ID, MEETING_ID)).rejects.toMatchObject({
message: 'Meeting recording state was not confirmed',
});
await expect(queueMeetingRecording(USER_ID, MEETING_ID, 1_000)).rejects.toMatchObject({
message: 'Queued meeting state was not confirmed',
});
await expect(completeMeetingProcessing(USER_ID, MEETING_ID, AUDIO_ID, IDEMPOTENCY, {
transcript: 't',
language: 'ko',
provider: 'whisper',
durationMs: 1_000,
sttLatencyMs: 10,
})).rejects.toMatchObject({ message: 'Meeting completion was not confirmed' });
});
});
describe('meeting recording state policy', () => {
test('lists the states each transition may produce', () => {
expect(expectedMeetingStates('recording-started')).toEqual(['recording']);
expect(expectedMeetingStates('recording-queued')).toEqual(['processing']);
expect(expectedMeetingStates('processing-completed')).toEqual(['completed']);
expect(expectedMeetingStates('capture-abandoned')).toEqual(['error', 'completed']);
});
test('confirms only the caller-owned meeting in an expected state', () => {
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'completed' }, USER_ID, 'capture-abandoned')).toBe(true);
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(true);
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'processing' }, USER_ID, 'capture-abandoned')).toBe(false);
expect(confirmsMeetingTransition({ user_id: OTHER_USER_ID, status: 'error' }, USER_ID, 'capture-abandoned')).toBe(false);
expect(confirmsMeetingTransition({ user_id: USER_ID, status: 'error' }, USER_ID, 'processing-completed')).toBe(false);
});
});

View file

@ -0,0 +1,148 @@
import React, { useEffect } from 'react'
import { AppState, type AppStateStatus } from 'react-native'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import AsyncStorage from '@react-native-async-storage/async-storage'
const mockMaybeSingle = jest.fn(async () => ({
data: null,
error: { message: 'network down', code: 'NETWORK' },
}))
jest.mock('../src/lib/auth-context', () => ({
useAuth: () => ({ user: { id: '22222222-2222-4222-8222-222222222222' } }),
}))
jest.mock('../src/lib/supabase', () => {
const channel = {
on: () => channel,
subscribe: () => channel,
}
const builder = {
select: () => builder,
eq: () => builder,
maybeSingle: () => mockMaybeSingle(),
}
return {
supabase: {
from: () => builder,
channel: () => channel,
removeChannel: jest.fn(async () => 'ok'),
},
}
})
import {
MobilePreferencesProvider,
useMobilePreferences,
} from '../src/lib/preferences-context'
type RetrySync = () => Promise<void>
const loadingHistory: boolean[] = []
let screenMounts = 0
let latestRetrySync: RetrySync | null = null
let latestSyncStatus: string | null = null
function Screen(): React.ReactElement | null {
useEffect(() => {
screenMounts += 1
}, [])
return null
}
function Gate(): React.ReactElement | null {
const { loading, retrySync, syncStatus } = useMobilePreferences()
loadingHistory.push(loading)
latestRetrySync = retrySync
latestSyncStatus = syncStatus
// Mirrors App.tsx: while loading, the navigation tree is replaced by a boot screen.
return loading ? null : <Screen />
}
async function flushAsync(): Promise<void> {
for (let index = 0; index < 20; index += 1) {
await Promise.resolve()
}
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
describe('mobile preferences background resync (redteam r3-18)', () => {
let appStateListeners: Array<(state: AppStateStatus) => void>
let renderer: ReactTestRenderer | null
beforeEach(async () => {
await AsyncStorage.clear()
loadingHistory.length = 0
screenMounts = 0
latestRetrySync = null
latestSyncStatus = null
renderer = null
appStateListeners = []
jest.spyOn(AppState, 'addEventListener').mockImplementation((type, listener) => {
if (type === 'change') {
appStateListeners.push(listener as (state: AppStateStatus) => void)
}
return {
remove: () => {
appStateListeners = appStateListeners.filter((entry) => entry !== listener)
},
}
})
})
afterEach(() => {
act(() => {
renderer?.unmount()
})
jest.restoreAllMocks()
})
async function renderOffline(): Promise<void> {
await act(async () => {
renderer = create(
<MobilePreferencesProvider>
<Gate />
</MobilePreferencesProvider>,
)
await flushAsync()
})
expect(latestSyncStatus).toBe('offline')
expect(loadingHistory[loadingHistory.length - 1]).toBe(false)
expect(screenMounts).toBe(1)
loadingHistory.length = 0
}
test('foreground resync after an offline start never unmounts the app tree', async () => {
await renderOffline()
act(() => {
for (const listener of appStateListeners) listener('active')
})
await act(async () => {
await flushAsync()
})
expect(mockMaybeSingle.mock.calls.length).toBeGreaterThanOrEqual(2)
expect(loadingHistory).not.toContain(true)
expect(screenMounts).toBe(1)
expect(latestSyncStatus).toBe('offline')
})
test('Settings retry with no pending patch keeps the current screen mounted', async () => {
await renderOffline()
const retry = latestRetrySync
if (retry === null) throw new Error('retrySync missing')
let pending: Promise<void> = Promise.resolve()
act(() => {
pending = retry()
})
await act(async () => {
await pending
await flushAsync()
})
expect(loadingHistory).not.toContain(true)
expect(screenMounts).toBe(1)
})
})

View file

@ -0,0 +1,356 @@
import {
createPreferencesStore,
preferencesOwnerFor,
getUserCacheKey,
INSTALLATION_CACHE_KEY,
type PreferencesRemote,
type PreferencesStorage,
type PreferencesStore,
type PreferencesStoreSnapshot,
type RemoteInsertResult,
type UserSettingsRowMutation,
} from '../src/lib/preferences-store'
const USER_A = 'user-a'
const USER_B = 'user-b'
const NOW = '2026-09-28T00:00:00.000Z'
function createMemoryStorage(): PreferencesStorage & { data: Map<string, string>, failReads: boolean, failWrites: boolean } {
const data = new Map<string, string>()
const storage = {
data,
failReads: false,
failWrites: false,
async multiGet(keys: readonly string[]) {
if (storage.failReads) throw new Error('read failed')
return keys.map((key) => [key, data.get(key) ?? null] as const)
},
async setItem(key: string, value: string) {
if (storage.failWrites) throw new Error('write failed')
data.set(key, value)
},
async multiSet(pairs: Array<[string, string]>) {
if (storage.failWrites) throw new Error('write failed')
for (const [key, value] of pairs) data.set(key, value)
},
async getAllKeys() {
return [...data.keys()]
},
async multiRemove(keys: readonly string[]) {
for (const key of keys) data.delete(key)
},
}
return storage
}
function row(userId: string, overrides: Partial<UserSettingsRowMutation> = {}): UserSettingsRowMutation {
return {
user_id: userId,
theme_mode: 'dark',
locale: 'en',
haptic_enabled: true,
auto_polish_enabled: true,
preferred_stt_model: null,
preferred_llm_model: null,
onboarding_version: 1,
tutorial_completed_at: null,
revision: 1,
...overrides,
}
}
interface FakeRemote extends PreferencesRemote {
rows: Map<string, UserSettingsRowMutation>
failFetch: boolean
fetchRow: jest.Mock<Promise<unknown>, [string]>
insertRow: jest.Mock<Promise<RemoteInsertResult>, [UserSettingsRowMutation]>
updateRowAtRevision: jest.Mock<Promise<unknown>, [UserSettingsRowMutation, number]>
}
function createFakeRemote(): FakeRemote {
const rows = new Map<string, UserSettingsRowMutation>()
const remote: FakeRemote = {
rows,
failFetch: false,
fetchRow: jest.fn(async (userId: string) => {
if (remote.failFetch) throw new Error('offline')
return rows.get(userId) ?? null
}),
insertRow: jest.fn(async (value: UserSettingsRowMutation): Promise<RemoteInsertResult> => {
if (rows.has(value.user_id)) return { status: 'duplicate' }
rows.set(value.user_id, value)
return { status: 'inserted', row: value }
}),
updateRowAtRevision: jest.fn(async (value: UserSettingsRowMutation, expected: number) => {
const current = rows.get(value.user_id)
if (current === undefined || current.revision !== expected) return null
rows.set(value.user_id, value)
return value
}),
}
return remote
}
async function settle(): Promise<void> {
for (let index = 0; index < 30; index += 1) await Promise.resolve()
}
function recordPhases(store: PreferencesStore): PreferencesStoreSnapshot[] {
const history: PreferencesStoreSnapshot[] = []
store.subscribe(() => history.push(store.getSnapshot()))
return history
}
describe('preferences store', () => {
test('first load of an owner enters initial-loading, then becomes ready', async () => {
const storage = createMemoryStorage()
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A))
const store = createPreferencesStore({ storage, remote, now: () => NOW })
const history = recordPhases(store)
await store.load(preferencesOwnerFor(USER_A))
expect(history[0]?.phase).toBe('initial-loading')
expect(store.getSnapshot()).toMatchObject({
ownerKey: `user:${USER_A}`,
phase: 'ready',
syncStatus: 'synced',
errorCode: null,
lastSyncedAt: NOW,
})
expect(store.getSnapshot().preferences.themeMode).toBe('dark')
})
test('resync after an offline start never re-enters initial-loading', async () => {
const storage = createMemoryStorage()
const remote = createFakeRemote()
remote.failFetch = true
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'offline' })
expect(store.needsForegroundResync()).toBe(true)
const history = recordPhases(store)
await store.resync()
remote.failFetch = false
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'light', revision: 4 }))
await store.resync()
expect(history.length).toBeGreaterThan(0)
expect(history.every((entry) => entry.phase === 'ready')).toBe(true)
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
expect(store.getSnapshot().preferences.themeMode).toBe('light')
expect(store.needsForegroundResync()).toBe(false)
})
test('reloading the same ready owner is a background refresh', async () => {
const store = createPreferencesStore({
storage: createMemoryStorage(),
remote: createFakeRemote(),
now: () => NOW,
})
await store.load(preferencesOwnerFor(null))
const history = recordPhases(store)
await store.load(preferencesOwnerFor(null))
expect(history.every((entry) => entry.phase === 'ready')).toBe(true)
})
test('concurrent resync calls share one refresh', async () => {
const remote = createFakeRemote()
remote.failFetch = true
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.fetchRow.mockClear()
await Promise.all([store.resync(), store.resync(), store.resync()])
expect(remote.fetchRow).toHaveBeenCalledTimes(1)
})
test('resync with a pending patch flushes it instead of reloading', async () => {
const storage = createMemoryStorage()
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { revision: 2 }))
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.failFetch = true
const result = await store.commit({ hapticEnabled: false })
expect(result).toEqual({ localSaved: true, serverSynced: false })
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_FAILED' })
remote.failFetch = false
await store.resync()
expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false, revision: 3 })
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
})
test('revision conflicts are retried against the latest row', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { revision: 5 }))
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.updateRowAtRevision.mockImplementationOnce(async () => {
// Another device bumps the revision between our read and write.
remote.rows.set(USER_A, row(USER_A, { revision: 6, locale: 'ko' }))
return null
})
const result = await store.commit({ themeMode: 'light' })
expect(result.serverSynced).toBe(true)
expect(remote.rows.get(USER_A)).toMatchObject({ theme_mode: 'light', locale: 'ko', revision: 7 })
})
test('repeated revision conflicts surface SYNC_CONFLICT', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A))
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.updateRowAtRevision.mockImplementation(async () => null)
const result = await store.commit({ themeMode: 'light' })
expect(result.serverSynced).toBe(false)
expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(3)
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'SYNC_CONFLICT' })
})
test('an insert race (unique violation) falls back to the revision-checked update', async () => {
const remote = createFakeRemote()
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
remote.insertRow.mockImplementationOnce(async () => {
remote.rows.set(USER_A, row(USER_A, { revision: 3 }))
return { status: 'duplicate' }
})
await store.load(preferencesOwnerFor(USER_A))
expect(remote.updateRowAtRevision).toHaveBeenCalledTimes(1)
expect(remote.rows.get(USER_A)?.revision).toBe(4)
expect(store.getSnapshot()).toMatchObject({ phase: 'ready', syncStatus: 'synced' })
})
test('a stale server response is dropped after the owner switches', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
remote.rows.set(USER_B, row(USER_B, { theme_mode: 'light' }))
let releaseA: () => void = () => undefined
remote.fetchRow.mockImplementationOnce(async (userId: string) => {
await new Promise<void>((resolve) => { releaseA = resolve })
return remote.rows.get(userId) ?? null
})
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
const loadA = store.load(preferencesOwnerFor(USER_A))
await settle()
await store.load(preferencesOwnerFor(USER_B))
releaseA()
await loadA
expect(store.getSnapshot()).toMatchObject({ ownerKey: `user:${USER_B}`, phase: 'ready' })
expect(store.getSnapshot().preferences.themeMode).toBe('light')
})
test('realtime rows are ignored while local edits are pending', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.failFetch = true
await store.commit({ themeMode: 'light' })
store.applyRemote(USER_A, row(USER_A, { theme_mode: 'system', revision: 9 }))
expect(store.getSnapshot().preferences.themeMode).toBe('light')
store.applyRemote(USER_B, row(USER_B, { theme_mode: 'system' }))
expect(store.getSnapshot().preferences.themeMode).toBe('light')
})
test('realtime rows apply when nothing is pending', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark' }))
const storage = createMemoryStorage()
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
store.applyRemote(USER_A, row(USER_A, { theme_mode: 'light', revision: 2 }))
await settle()
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'synced', phase: 'ready' })
expect(store.getSnapshot().preferences.themeMode).toBe('light')
expect(storage.data.get(getUserCacheKey(USER_A))).toContain('"themeMode":"light"')
})
test('cache read failure outranks the sync failure code', async () => {
const storage = createMemoryStorage()
storage.failReads = true
const remote = createFakeRemote()
remote.failFetch = true
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_READ_FAILED' })
})
test('cache write failure outranks the sync failure code', async () => {
const storage = createMemoryStorage()
storage.failWrites = true
const remote = createFakeRemote()
remote.failFetch = true
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'offline', errorCode: 'CACHE_WRITE_FAILED' })
})
test('signed-out commits stay local and persist a pending installation patch', async () => {
const storage = createMemoryStorage()
const remote = createFakeRemote()
const store = createPreferencesStore({ storage, remote, now: () => NOW })
await store.load(preferencesOwnerFor(null))
const result = await store.commit({ locale: 'en' })
expect(result).toEqual({ localSaved: true, serverSynced: false })
expect(store.getSnapshot()).toMatchObject({ syncStatus: 'local', ownerKey: 'installation' })
expect(storage.data.get(INSTALLATION_CACHE_KEY)).toContain('"pendingPatch":{"locale":"en"}')
expect(remote.fetchRow).not.toHaveBeenCalled()
expect(store.needsForegroundResync()).toBe(false)
})
test('a commit made during a background refresh stays pending on top of the server value', async () => {
const remote = createFakeRemote()
remote.rows.set(USER_A, row(USER_A, { theme_mode: 'dark', revision: 2 }))
const store = createPreferencesStore({ storage: createMemoryStorage(), remote, now: () => NOW })
await store.load(preferencesOwnerFor(USER_A))
remote.failFetch = true
await store.resync() // -> offline, nothing pending
remote.failFetch = false
let releaseFetch: () => void = () => undefined
remote.fetchRow.mockImplementationOnce(async (userId: string) => {
await new Promise<void>((resolve) => { releaseFetch = resolve })
return remote.rows.get(userId) ?? null
})
const refresh = store.resync()
await settle()
const commit = store.commit({ hapticEnabled: false })
await settle()
releaseFetch()
await refresh
const result = await commit
expect(result.serverSynced).toBe(true)
expect(store.getSnapshot().preferences.hapticEnabled).toBe(false)
expect(remote.rows.get(USER_A)).toMatchObject({ haptic_enabled: false })
expect(store.getSnapshot().phase).toBe('ready')
})
})

View file

@ -0,0 +1,314 @@
import { FileSystem } from 'react-native-file-access'
import { AudioPipelineError, type LocalAudioInput } from '../src/features/import/audio-import-types'
import { transcribeAudioLocally } from '../src/features/import/local-whisper-transcription'
import { classifySttResponse, parseSttResult } from '../src/features/import/stt-cloud-client'
import { transcribeWithLocalFallback } from '../src/features/import/stt-engine'
import { isRetryable, shouldFallBackToLocal } from '../src/features/import/stt-policy'
import { processAudioInput } from '../src/features/import/audio-transcription-service'
import { transcribeTalkRecording } from '../src/features/talk/talk-transcription-service'
import { prepareRecordedAudio } from '../src/features/import/recorded-audio-input'
import {
beginMeetingProcessing,
completeMeetingProcessing,
} from '../src/features/meetings/meetings-service'
jest.mock('../src/features/import/local-whisper-transcription', () => ({
transcribeAudioLocally: jest.fn(),
}))
jest.mock('../src/features/import/recorded-audio-input', () => ({
prepareRecordedAudio: jest.fn(),
}))
jest.mock('../src/features/import/resumable-audio-upload', () => ({
uploadAudioResumably: jest.fn(),
}))
jest.mock('../src/features/meetings/meetings-service', () => ({
beginMeetingProcessing: jest.fn(),
completeMeetingProcessing: jest.fn(),
}))
jest.mock('../src/lib/native-config', () => ({
getMobileRuntimeConfig: () => ({ appVersion: '1.9.0' }),
}))
interface RecordedQuery {
table: string
calls: Array<[string, unknown[]]>
}
type QueryResult = { data: unknown; error: unknown }
const mockQueries: RecordedQuery[] = []
let mockResolveQuery: (query: RecordedQuery) => QueryResult = () => ({ data: null, error: null })
function mockBuilder(table: string): Record<string, unknown> {
const recorded: RecordedQuery = { table, calls: [] }
mockQueries.push(recorded)
const builder: Record<string, unknown> = {}
for (const method of ['select', 'insert', 'update', 'delete', 'eq', 'neq', 'order', 'limit']) {
builder[method] = (...args: unknown[]) => {
recorded.calls.push([method, args])
return builder
}
}
for (const terminal of ['maybeSingle', 'single']) {
builder[terminal] = async () => {
recorded.calls.push([terminal, []])
return mockResolveQuery(recorded)
}
}
builder.then = (
onFulfilled: (value: QueryResult) => unknown,
onRejected?: (reason: unknown) => unknown,
) => Promise.resolve(mockResolveQuery(recorded)).then(onFulfilled, onRejected)
return builder
}
jest.mock('../src/lib/supabase', () => ({
supabase: {
auth: {
getSession: jest.fn(async () => ({
data: { session: { user: { id: '00000000-0000-4000-8000-000000000001' }, access_token: 'user-token' } },
error: null,
})),
},
from: jest.fn((table: string) => mockBuilder(table)),
},
}))
const originalFetch = global.fetch
const SHA = 'a'.repeat(64)
const USER_ID = '00000000-0000-4000-8000-000000000001'
function input(): LocalAudioInput {
return {
uri: 'file:///cache/meeting.m4a',
path: '/cache/meeting.m4a',
fileName: 'meeting.m4a',
mimeType: 'audio/mp4',
sizeBytes: 3,
durationMs: 1_500,
source: 'recording',
dispose: jest.fn().mockResolvedValue(undefined),
}
}
function uploadedAudio(meetingId: string | null): Record<string, unknown> {
return {
id: 'audio-1',
user_id: USER_ID,
storage_key: `${USER_ID}/imports/${SHA}/meeting.m4a`,
upload_status: 'uploaded',
history_id: null,
meeting_id: meetingId,
}
}
function historyWrites(): RecordedQuery[] {
return mockQueries.filter(query =>
query.table === 'history'
&& query.calls.some(([method]) => method === 'insert' || method === 'update'))
}
function writtenPayload(query: RecordedQuery): Record<string, unknown> {
const call = query.calls.find(([method]) => method === 'insert' || method === 'update')
return (call?.[1][0] ?? {}) as Record<string, unknown>
}
beforeEach(() => {
jest.clearAllMocks()
mockQueries.length = 0
;(FileSystem.hash as jest.Mock).mockResolvedValue(SHA)
;(FileSystem.readFile as jest.Mock).mockResolvedValue('AQID')
})
afterEach(() => {
global.fetch = originalFetch
})
describe('stt-proxy status classification (single contract table)', () => {
it.each([
[200, null],
[400, 'transcription'],
[401, 'auth'],
[403, 'auth'],
[413, 'file-too-large'],
[415, 'transcription'],
[422, 'no-speech'],
[429, 'quota'],
[500, 'transcription'],
[502, 'provider-unavailable'],
[503, 'provider-unavailable'],
[504, 'provider-unavailable'],
])('HTTP %i -> %s', (status, code) => {
const error = classifySttResponse(status, '{"error":"x"}')
expect(error === null ? null : error.code).toBe(code)
})
it('keeps the server error code in the message for diagnostics', () => {
expect(classifySttResponse(422, '{"error":"stt_no_speech","attempts":[]}')?.message)
.toContain('stt_no_speech')
})
it('accepts a result without confidence and rejects an empty transcript', () => {
expect(parseSttResult({
transcript: ' hi ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram',
}, 5)).toMatchObject({ text: 'hi', confidence: null })
expect(() => parseSttResult({
transcript: ' ', language_code: 'ko', duration_seconds: 1, provider: 'deepgram', confidence: 1,
}, 5)).toThrow(expect.objectContaining({ code: 'transcription' }))
})
})
describe('stt policy', () => {
it('only lets provider/transport failures fall back to on-device whisper', () => {
expect(shouldFallBackToLocal('provider-unavailable')).toBe(true)
expect(shouldFallBackToLocal('transcription')).toBe(true)
for (const code of ['no-speech', 'auth', 'quota', 'file-too-large', 'cancelled', 'file-read'] as const) {
expect(shouldFallBackToLocal(code)).toBe(false)
}
})
it('keeps retry separate from fallback: auth retries but never falls back, no-speech does neither', () => {
expect(isRetryable('auth')).toBe(true)
expect(shouldFallBackToLocal('auth')).toBe(false)
expect(isRetryable('no-speech')).toBe(false)
expect(isRetryable('quota')).toBe(false)
})
})
describe('transcribeWithLocalFallback', () => {
const signal = new AbortController().signal
it('does not run the local engine when the cloud reports no speech', async () => {
const local = jest.fn()
await expect(transcribeWithLocalFallback({
input: input(),
languageCode: 'ko',
signal,
cloud: async () => { throw new AudioPipelineError('no-speech', 'silent') },
local,
})).rejects.toMatchObject({ code: 'no-speech' })
expect(local).not.toHaveBeenCalled()
})
it('turns an empty local transcript into no-speech instead of a result', async () => {
const onFallback = jest.fn()
await expect(transcribeWithLocalFallback({
input: input(),
languageCode: 'ko',
signal,
cloud: async () => { throw new AudioPipelineError('provider-unavailable', 'down') },
local: async () => ({
text: '', confidence: null, language: 'ko', durationSeconds: 1, provider: 'local', latencyMs: 1,
}),
onFallback,
})).rejects.toMatchObject({ code: 'no-speech' })
expect(onFallback).toHaveBeenCalledWith(expect.objectContaining({ code: 'provider-unavailable' }))
})
})
describe('file/meeting pipeline with stt-proxy 422 stt_no_speech', () => {
function respondNoSpeech(): jest.Mock {
const fetchMock = jest.fn().mockResolvedValue({
ok: false,
status: 422,
text: async () => JSON.stringify({ error: 'stt_no_speech', attempts: [] }),
})
global.fetch = fetchMock
return fetchMock
}
it('fails a history transcription as no-speech without local fallback and records the failure', async () => {
mockResolveQuery = query => {
if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) {
return { data: uploadedAudio(null), error: null }
}
if (query.table === 'history') return { data: { id: 'history-1' }, error: null }
return { data: null, error: null }
}
const fetchMock = respondNoSpeech()
await expect(processAudioInput(input(), {
expectedUserId: USER_ID,
languageCode: 'ko',
signal: new AbortController().signal,
})).rejects.toMatchObject({ code: 'no-speech' })
expect(fetchMock).toHaveBeenCalledTimes(1)
expect(transcribeAudioLocally).not.toHaveBeenCalled()
const writes = historyWrites()
expect(writes).toHaveLength(1)
expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' })
})
it('fails a meeting transcription as no-speech without completing it or running whisper', async () => {
mockResolveQuery = query => {
if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) {
return { data: uploadedAudio('meeting-1'), error: null }
}
return { data: null, error: null }
}
;(beginMeetingProcessing as jest.Mock).mockResolvedValue({ id: 'job-1' })
respondNoSpeech()
await expect(processAudioInput(input(), {
expectedUserId: USER_ID,
languageCode: 'ko',
signal: new AbortController().signal,
meetingId: 'meeting-1',
})).rejects.toMatchObject({ code: 'no-speech' })
expect(transcribeAudioLocally).not.toHaveBeenCalled()
expect(completeMeetingProcessing).not.toHaveBeenCalled()
})
it('does not save an empty on-device transcript as a completed history', async () => {
mockResolveQuery = query => {
if (query.table === 'audio_files' && query.calls.some(([method]) => method === 'neq')) {
return { data: uploadedAudio(null), error: null }
}
if (query.table === 'history') return { data: { id: 'history-1' }, error: null }
return { data: null, error: null }
}
global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 503, text: async () => '' })
;(transcribeAudioLocally as jest.Mock).mockResolvedValue({
text: '', confidence: null, language: 'ko', durationSeconds: 1.5, provider: 'whisper.cpp-tiny-local', latencyMs: 10,
})
await expect(processAudioInput(input(), {
expectedUserId: USER_ID,
languageCode: 'ko',
signal: new AbortController().signal,
})).rejects.toMatchObject({ code: 'no-speech' })
const writes = historyWrites()
expect(writes).toHaveLength(1)
expect(writtenPayload(writes[0])).toMatchObject({ status: 'error', error_code: 'no-speech' })
})
})
describe('Talk with stt-proxy 422 stt_no_speech', () => {
it('reports no-speech without running on-device whisper', async () => {
const recorded = input()
;(prepareRecordedAudio as jest.Mock).mockResolvedValue(recorded)
global.fetch = jest.fn().mockResolvedValue({
ok: false,
status: 422,
text: async () => JSON.stringify({ error: 'stt_no_speech' }),
})
await expect(transcribeTalkRecording({
uri: recorded.uri,
path: recorded.path,
fileName: recorded.fileName,
mimeType: recorded.mimeType,
size: recorded.sizeBytes,
durationMs: 1_500,
}, {
accessToken: 'user-token',
languageCode: 'ko',
signal: new AbortController().signal,
disposeRecording: jest.fn(),
})).rejects.toMatchObject({ code: 'no-speech' })
expect(transcribeAudioLocally).not.toHaveBeenCalled()
expect(recorded.dispose).toHaveBeenCalledTimes(1)
})
})

View file

@ -0,0 +1,122 @@
// src/features/account/account-deletion-service.ts — account deletion use case.
//
// The screen only renders; the server contract (account-delete Edge Function),
// the failure mapping and the side-effect ordering live here so they can be
// tested without rendering UI.
import {
edgeFailureField,
invokeEdgeFunction,
type EdgeFunctionFailure,
type EdgeFunctionInvoker,
} from '../../lib/edge-functions'
export type AccountDeletionFailureCode =
| 'SERVER_ENDPOINT_UNAVAILABLE'
| 'REAUTHENTICATION_REQUIRED'
| 'ACTIVE_SUBSCRIPTION'
| 'REQUEST_FAILED'
export type AccountDeletionResult =
| { ok: true }
| { ok: false; code: AccountDeletionFailureCode; message?: string }
/** Server-owned boundary for deleting auth.users and its cascaded account data. */
export interface AccountDeletionService {
deleteCurrentAccount(): Promise<AccountDeletionResult>
}
export const ACCOUNT_DELETE_FUNCTION = 'account-delete'
export const ACCOUNT_DELETE_CONFIRMATION = 'DELETE_MY_ACCOUNT'
/**
* account-delete puts the machine code in `code` and an English diagnostic in
* `error`. The diagnostic is kept for logs only; UI copy comes from i18n.
*/
export function mapAccountDeletionFailure(failure: EdgeFunctionFailure): AccountDeletionResult {
const serverCode = edgeFailureField(failure, 'code')
const message = edgeFailureField(failure, 'error') ?? failure.message ?? undefined
if (serverCode === 'REAUTHENTICATION_REQUIRED') {
return { ok: false, code: 'REAUTHENTICATION_REQUIRED', message }
}
if (serverCode === 'ACTIVE_SUBSCRIPTION') {
return { ok: false, code: 'ACTIVE_SUBSCRIPTION', message }
}
if (failure.status === 404) {
return { ok: false, code: 'SERVER_ENDPOINT_UNAVAILABLE', message }
}
return { ok: false, code: 'REQUEST_FAILED', message }
}
export function createEdgeAccountDeletionService(
invoke: EdgeFunctionInvoker = invokeEdgeFunction,
): AccountDeletionService {
return Object.freeze({
async deleteCurrentAccount(): Promise<AccountDeletionResult> {
const result = await invoke<{ success?: unknown; error?: unknown }>(
ACCOUNT_DELETE_FUNCTION,
{ confirmation: ACCOUNT_DELETE_CONFIRMATION },
)
if (!result.ok) return mapAccountDeletionFailure(result)
if (result.data?.success !== true) {
const message = typeof result.data?.error === 'string' ? result.data.error : undefined
return { ok: false, code: 'REQUEST_FAILED', message }
}
return { ok: true }
},
})
}
export const edgeAccountDeletionService: AccountDeletionService = createEdgeAccountDeletionService()
export type AccountDeletionFailureMessageKey =
| 'mobile.account.deleteUnavailable'
| 'mobile.account.reauthenticationRequired'
| 'mobile.account.deleteFailed'
/** i18n key for a deletion failure. Raw server/SDK text is never shown. */
export function accountDeletionFailureMessageKey(
code: AccountDeletionFailureCode,
): AccountDeletionFailureMessageKey {
switch (code) {
case 'SERVER_ENDPOINT_UNAVAILABLE':
return 'mobile.account.deleteUnavailable'
case 'REAUTHENTICATION_REQUIRED':
return 'mobile.account.reauthenticationRequired'
default:
return 'mobile.account.deleteFailed'
}
}
export interface AccountDeletionWorkflow {
deletionService: AccountDeletionService
/**
* Detaches this device's push registration. Runs only after the server has
* confirmed deletion, so a refused deletion (re-auth, active subscription)
* leaves the still-live account's notifications intact.
*/
detachPushRegistration: () => Promise<void>
/** Removes every account-owned local artifact and the secure session. */
purgeLocalSession: () => Promise<void>
}
/**
* Deletes the current account. Order: server deletion → push detach
* (best-effort, needs the session) → local purge. A failed deletion performs
* no side effect. A purge failure propagates to the caller.
*/
export async function runAccountDeletion(
workflow: AccountDeletionWorkflow,
): Promise<AccountDeletionResult> {
const result = await workflow.deletionService.deleteCurrentAccount()
if (!result.ok) return result
// The account no longer exists, so a push-detach failure must not block the
// local privacy purge; detachPushRegistrationForLogout already tolerates a
// single-boundary failure and only throws when both boundaries fail.
try {
await workflow.detachPushRegistration()
} catch {
// Best-effort by design; see above.
}
await workflow.purgeLocalSession()
return result
}

View file

@ -21,16 +21,20 @@ import {
countTranscriptWords,
decodeBase64,
} from './audio-validation';
import { createMultipartAudioBody } from './multipart-audio';
import { createLinkedDeadline } from './linked-deadline';
import { transcribeAudioLocally } from './local-whisper-transcription';
import { uploadAudioResumably } from './resumable-audio-upload';
import {
beginMeetingProcessing,
completeMeetingProcessing,
} from '../meetings/meetings-service';
import { transcribeCloud } from './stt-cloud-client';
import { transcribeWithLocalFallback } from './stt-engine';
import { createLogger } from '../../lib/logger';
const UPLOAD_TIMEOUT_MS = 120_000;
const TRANSCRIPTION_TIMEOUT_MS = 300_000;
const log = createLogger('AudioPipeline');
interface OperationState {
audioFile: AudioFile | null;
@ -41,12 +45,6 @@ interface OperationState {
processingJobId: string | null;
}
interface LinkedDeadline {
signal: AbortSignal;
timedOut: () => boolean;
dispose: () => void;
}
function database(): D3roSupabaseClient {
return supabase as unknown as D3roSupabaseClient;
}
@ -78,31 +76,6 @@ async function requireCurrentSession(
return session;
}
function createLinkedDeadline(
parentSignal: AbortSignal,
timeoutMs: number,
): LinkedDeadline {
const controller = new AbortController();
let didTimeOut = false;
const abortFromParent = (): void => controller.abort();
parentSignal.addEventListener('abort', abortFromParent, { once: true });
const timeout = setTimeout(() => {
didTimeOut = true;
controller.abort();
}, timeoutMs);
if (parentSignal.aborted) controller.abort();
return {
signal: controller.signal,
timedOut: () => didTimeOut,
dispose: () => {
clearTimeout(timeout);
parentSignal.removeEventListener('abort', abortFromParent);
},
};
}
function encodedStoragePath(storageKey: string): string {
return storageKey.split('/').map(encodeURIComponent).join('/');
}
@ -259,122 +232,6 @@ async function uploadAudio(
}
}
function parseTranscriptionResponse(
value: unknown,
latencyMs: number,
): MobileTranscriptionResult {
if (typeof value !== 'object' || value === null) {
throw new AudioPipelineError(
'transcription',
'The transcription service returned an invalid response',
);
}
const candidate = value as Record<string, unknown>;
if (
typeof candidate.transcript !== 'string' ||
typeof candidate.confidence !== 'number' ||
!Number.isFinite(candidate.confidence) ||
typeof candidate.language_code !== 'string' ||
candidate.language_code.trim() === '' ||
typeof candidate.duration_seconds !== 'number' ||
!Number.isFinite(candidate.duration_seconds) ||
candidate.duration_seconds < 0 ||
typeof candidate.provider !== 'string' ||
candidate.provider.trim() === ''
) {
throw new AudioPipelineError(
'transcription',
'The transcription service returned incomplete audio results',
);
}
return {
text: candidate.transcript,
confidence: Math.max(0, Math.min(1, candidate.confidence)),
language: candidate.language_code,
durationSeconds: candidate.duration_seconds,
provider: candidate.provider,
latencyMs,
};
}
async function transcribeAudioFile(
input: LocalAudioInput,
audioBytes: Uint8Array,
session: Session,
languageCode: string,
parentSignal: AbortSignal,
): Promise<MobileTranscriptionResult> {
const multipart = createMultipartAudioBody(input, audioBytes, languageCode);
const deadline = createLinkedDeadline(parentSignal, TRANSCRIPTION_TIMEOUT_MS);
const startedAt = Date.now();
try {
const response = await fetch(`${SUPABASE_URL}/functions/v1/stt-proxy`, {
method: 'POST',
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${session.access_token}`,
'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`,
},
body: multipart.body,
signal: deadline.signal,
});
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AudioPipelineError(
'auth',
'The transcription session is no longer authorized',
);
}
if (response.status === 413) {
throw new AudioPipelineError(
'file-too-large',
'The transcription service rejected the file size',
);
}
if (response.status === 429) {
throw new AudioPipelineError(
'quota',
'Your transcription quota has been reached',
);
}
if (response.status === 503) {
throw new AudioPipelineError(
'provider-unavailable',
'No transcription provider is currently available',
);
}
throw new AudioPipelineError(
'transcription',
`Transcription failed with status ${response.status}`,
);
}
const body: unknown = await response.json();
return parseTranscriptionResponse(body, Date.now() - startedAt);
} catch (error) {
if (error instanceof AudioPipelineError) throw error;
if (parentSignal.aborted) {
throw new AudioPipelineError('cancelled', 'Transcription was cancelled');
}
if (deadline.timedOut()) {
throw new AudioPipelineError(
'provider-unavailable',
'The transcription service timed out',
error,
);
}
throw new AudioPipelineError(
'transcription',
'The transcription service could not be reached',
error,
);
} finally {
deadline.dispose();
}
}
function historyTitle(fileName: string): string {
const withoutExtension = fileName.replace(/\.[^.]+$/, '').trim();
return (withoutExtension === '' ? 'Audio transcription' : withoutExtension).slice(0, 160);
@ -741,52 +598,28 @@ async function processCore(
options.signal,
);
state.lastAccessToken = transcriptionSession.access_token;
let transcription: MobileTranscriptionResult;
try {
transcription = await transcribeAudioFile(
const transcription: MobileTranscriptionResult = await transcribeWithLocalFallback({
input,
languageCode: options.languageCode,
signal: options.signal,
cloud: () => transcribeCloud({
input,
audioBytes,
transcriptionSession,
options.languageCode,
options.signal,
);
} catch (cloudError) {
if (
!(cloudError instanceof AudioPipelineError) ||
(cloudError.code !== 'provider-unavailable' &&
cloudError.code !== 'transcription')
) {
throw cloudError;
}
audioBytes = new Uint8Array(0);
try {
transcription = await transcribeAudioLocally(
input,
options.languageCode,
options.signal,
bytes: audioBytes,
accessToken: transcriptionSession.access_token,
languageCode: options.languageCode,
signal: options.signal,
timeoutMs: TRANSCRIPTION_TIMEOUT_MS,
}),
local: transcribeAudioLocally,
onFallback: cloudError => {
// Release the uploaded bytes before on-device Whisper decodes the file.
audioBytes = new Uint8Array(0);
log.warn(
'cloud STT failed, falling back to on-device whisper:',
`${cloudError.code}: ${cloudError.message}`,
);
} catch (localError) {
if (
localError instanceof AudioPipelineError &&
localError.code === 'cancelled'
) {
throw localError;
}
if (localError instanceof AudioPipelineError) {
throw new AudioPipelineError(
localError.code,
localError.message,
{ cloudError, localError },
);
}
throw new AudioPipelineError(
'transcription',
'Cloud and on-device transcription both failed',
{ cloudError, localError },
);
}
}
},
});
options.onPhase?.('saving');
await requireCurrentSession(options.expectedUserId, options.signal);

View file

@ -0,0 +1,35 @@
/**
* An AbortSignal that fires when either the parent signal aborts or the
* deadline elapses. `timedOut()` tells the two apart so callers can map a
* deadline to a retryable provider error and a parent abort to cancellation.
*/
export interface LinkedDeadline {
signal: AbortSignal;
timedOut: () => boolean;
dispose: () => void;
}
export function createLinkedDeadline(
parentSignal: AbortSignal,
timeoutMs: number,
): LinkedDeadline {
const controller = new AbortController();
let didTimeOut = false;
const abortFromParent = (): void => controller.abort();
parentSignal.addEventListener('abort', abortFromParent, { once: true });
const timeout = setTimeout(() => {
didTimeOut = true;
controller.abort();
}, timeoutMs);
if (parentSignal.aborted) controller.abort();
return {
signal: controller.signal,
timedOut: () => didTimeOut,
dispose: () => {
clearTimeout(timeout);
parentSignal.removeEventListener('abort', abortFromParent);
},
};
}

View file

@ -0,0 +1,170 @@
import { SUPABASE_ANON_KEY, SUPABASE_URL } from '@d3ro/core/supabase-config';
import {
AudioPipelineError,
type LocalAudioInput,
type MobileTranscriptionResult,
} from './audio-import-types';
import { createLinkedDeadline } from './linked-deadline';
import { createMultipartAudioBody } from './multipart-audio';
/**
* The one mobile client for the `stt-proxy` edge function. Talk and the
* file/meeting pipeline both go through here so the HTTP contract (status
* table, response shape, timeout/cancel mapping) is defined exactly once.
*/
export const STT_PROXY_ENDPOINT = `${SUPABASE_URL}/functions/v1/stt-proxy`;
const MAX_BODY_SNIPPET = 160;
export interface CloudSttRequest {
input: LocalAudioInput;
bytes: Uint8Array;
accessToken: string;
languageCode: string;
signal: AbortSignal;
/** Talk waits 120 s, the file/meeting pipeline 300 s. */
timeoutMs: number;
}
function bodySnippet(bodyText: string): string {
return bodyText.replace(/\s+/g, ' ').trim().slice(0, MAX_BODY_SNIPPET);
}
function describe(status: number, bodyText: string): string {
const snippet = bodySnippet(bodyText);
return snippet.length === 0 ? `HTTP ${status}` : `HTTP ${status}: ${snippet}`;
}
/**
* Maps an stt-proxy HTTP status (and its body, for diagnostics) to the
* pipeline error it stands for. Returns null for a 2xx answer.
*
* stt-proxy contract (server/supabase/functions/stt-proxy/handler.ts):
* - 401/403 auth rejected -> 'auth'
* - 413 audio_too_large -> 'file-too-large'
* - 422 stt_no_speech (quota already billed) -> 'no-speech'
* - 429 quota_exceeded -> 'quota'
* - 502 stt_upstream_failed / 503 stt_provider_unavailable / 504 gateway
* -> 'provider-unavailable'
* - anything else (400, 415, 500, ...) -> 'transcription'
*/
export function classifySttResponse(
status: number,
bodyText = '',
): AudioPipelineError | null {
if (status >= 200 && status < 300) return null;
const detail = describe(status, bodyText);
switch (status) {
case 401:
case 403:
return new AudioPipelineError('auth', `The speech session is no longer authorized (${detail})`);
case 413:
return new AudioPipelineError('file-too-large', `The speech service rejected the file size (${detail})`);
case 422:
return new AudioPipelineError('no-speech', `No speech was detected in the recording (${detail})`);
case 429:
return new AudioPipelineError('quota', `The speech transcription quota is exhausted (${detail})`);
case 502:
case 503:
case 504:
return new AudioPipelineError('provider-unavailable', `Cloud STT unavailable (${detail})`);
default:
return new AudioPipelineError('transcription', `Speech transcription failed (${detail})`);
}
}
function nonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
/**
* Parses a 2xx stt-proxy body. `confidence` is optional (not every provider
* reports one) and an empty transcript is rejected: stt-proxy reports silence
* as 422, so an empty 2xx transcript is an unusable response, not a result.
*/
export function parseSttResult(value: unknown, latencyMs: number): MobileTranscriptionResult {
if (typeof value !== 'object' || value === null) {
throw new AudioPipelineError('transcription', 'The speech service returned invalid data');
}
const body = value as Record<string, unknown>;
if (
!nonEmptyString(body.transcript)
|| !nonEmptyString(body.language_code)
|| typeof body.duration_seconds !== 'number'
|| !Number.isFinite(body.duration_seconds)
|| body.duration_seconds < 0
|| !nonEmptyString(body.provider)
) {
throw new AudioPipelineError('transcription', 'The speech service returned incomplete data');
}
const confidence = typeof body.confidence === 'number' && Number.isFinite(body.confidence)
? Math.max(0, Math.min(1, body.confidence))
: null;
return {
text: body.transcript.trim(),
confidence,
language: body.language_code.trim(),
durationSeconds: body.duration_seconds,
provider: body.provider.trim(),
latencyMs,
};
}
/** Rejects a missing bearer token before any audio leaves the device. */
export function requireSttAccessToken(accessToken: string): string {
const token = accessToken.trim();
if (token.length === 0) {
throw new AudioPipelineError('auth', 'Sign in to transcribe speech');
}
return token;
}
async function readBodyText(response: Response): Promise<string> {
try {
return typeof response.text === 'function' ? await response.text() : '';
} catch {
return '';
}
}
export async function transcribeCloud(request: CloudSttRequest): Promise<MobileTranscriptionResult> {
const token = requireSttAccessToken(request.accessToken);
if (request.signal.aborted) {
throw new AudioPipelineError('cancelled', 'Speech transcription was cancelled');
}
const multipart = createMultipartAudioBody(request.input, request.bytes, request.languageCode);
const deadline = createLinkedDeadline(request.signal, request.timeoutMs);
const startedAt = Date.now();
try {
const response = await fetch(STT_PROXY_ENDPOINT, {
method: 'POST',
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${token}`,
'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`,
},
body: multipart.body,
signal: deadline.signal,
});
if (!response.ok) {
const failure = classifySttResponse(response.status, await readBodyText(response))
?? new AudioPipelineError('transcription', `Speech transcription failed (HTTP ${response.status})`);
throw failure;
}
return parseSttResult(await response.json(), Date.now() - startedAt);
} catch (error) {
if (error instanceof AudioPipelineError) throw error;
if (request.signal.aborted) {
throw new AudioPipelineError('cancelled', 'Speech transcription was cancelled', error);
}
if (deadline.timedOut()) {
throw new AudioPipelineError('provider-unavailable', 'Cloud speech recognition timed out', error);
}
throw new AudioPipelineError('transcription', 'Cloud speech recognition could not be reached', error);
} finally {
deadline.dispose();
}
}

View file

@ -0,0 +1,74 @@
import {
AudioPipelineError,
type LocalAudioInput,
type MobileTranscriptionResult,
} from './audio-import-types';
import { shouldFallBackToLocal } from './stt-policy';
/**
* Port for anything that turns a local audio file into a transcript. The
* on-device Whisper runner (`transcribeAudioLocally`) satisfies it directly.
*/
export type SttEngine = (
input: LocalAudioInput,
languageCode: string,
signal: AbortSignal,
) => Promise<MobileTranscriptionResult>;
export interface FallbackTranscriptionRequest {
input: LocalAudioInput;
languageCode: string;
signal: AbortSignal;
/** The primary (cloud) attempt, already bound to its credentials and bytes. */
cloud: () => Promise<MobileTranscriptionResult>;
/** The secondary engine, used only when stt-policy allows it. */
local: SttEngine;
/** Observes the cloud failure right before the local engine runs. */
onFallback?: (cloudError: AudioPipelineError) => void;
}
/**
* Shared "cloud first, on-device second" orchestration for Talk and the
* file/meeting pipeline:
*
* 1. Any cloud error that stt-policy does not mark as fallback-eligible is
* rethrown untouched (auth, quota, no-speech, cancelled, ...).
* 2. A local failure keeps its own code and message and carries both errors
* as its cause; a local cancellation is rethrown as-is.
* 3. An empty local transcript is 'no-speech', never a completed result.
*/
export async function transcribeWithLocalFallback(
request: FallbackTranscriptionRequest,
): Promise<MobileTranscriptionResult> {
let cloudError: AudioPipelineError;
try {
return await request.cloud();
} catch (error) {
if (!(error instanceof AudioPipelineError) || !shouldFallBackToLocal(error.code)) {
throw error;
}
cloudError = error;
}
request.onFallback?.(cloudError);
let result: MobileTranscriptionResult;
try {
result = await request.local(request.input, request.languageCode, request.signal);
} catch (localError) {
if (localError instanceof AudioPipelineError) {
if (localError.code === 'cancelled') throw localError;
throw new AudioPipelineError(localError.code, localError.message, { cloudError, localError });
}
throw new AudioPipelineError(
'transcription',
'Cloud and on-device speech recognition both failed',
{ cloudError, localError },
);
}
if (result.text.trim().length === 0) {
throw new AudioPipelineError('no-speech', 'No speech was detected in the recording', cloudError);
}
return result;
}

View file

@ -0,0 +1,45 @@
import type { AudioPipelineErrorCode } from './audio-import-types';
/**
* Single source of truth for how the mobile STT pipelines react to an
* AudioPipelineError code. Pure and dependency-free so the durable queue, the
* file/meeting pipeline and Talk can all share it without pulling in IO.
*
* The two predicates are deliberately independent: an expired session
* ('auth') is worth retrying later from the queue, but must never be
* "rescued" by running on-device Whisper, which would bypass authentication
* and the server quota.
*/
/**
* Cloud failures that may be answered by on-device Whisper instead.
*
* - 'provider-unavailable': no provider configured, upstream outage, timeout.
* - 'transcription': unreachable service or an unusable response.
*
* Everything else is authoritative and must surface as-is. In particular
* 'no-speech' (stt-proxy 422 stt_no_speech) is a real, already billed answer:
* re-running a local model on silent audio only produces an empty or
* hallucinated transcript.
*/
const LOCAL_FALLBACK_CODES: ReadonlySet<AudioPipelineErrorCode> = new Set<AudioPipelineErrorCode>([
'provider-unavailable',
'transcription',
]);
/** Failures a durable queue item may be attempted again for. */
const RETRYABLE_CODES: ReadonlySet<AudioPipelineErrorCode> = new Set<AudioPipelineErrorCode>([
'upload',
'provider-unavailable',
'transcription',
'persist',
'auth',
]);
export function shouldFallBackToLocal(code: AudioPipelineErrorCode): boolean {
return LOCAL_FALLBACK_CODES.has(code);
}
export function isRetryable(code: AudioPipelineErrorCode): boolean {
return RETRYABLE_CODES.has(code);
}

View file

@ -0,0 +1,110 @@
import { AppState } from 'react-native'
/**
* Realtime policy for the mobile Knowledge screen.
*
* Supabase Postgres Changes does not apply RLS to DELETE events and cannot
* filter them, so an unfiltered DELETE subscription would deliver every
* account's deleted primary keys to every subscriber and make each client
* refetch on every deletion anywhere (N-client fan-out). Only INSERT/UPDATE are
* subscribed (RLS-scoped, optionally narrowed to the owner), and deletions are
* reconciled by foreground polling, mirroring use-history-sync.ts.
*/
export type KnowledgeRealtimeEvent = 'INSERT' | 'UPDATE'
export interface KnowledgeRealtimeBinding {
event: KnowledgeRealtimeEvent
schema: 'public'
table: 'knowledge_documents'
filter?: string
}
export const KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS = 45_000
const SUBSCRIBED_EVENTS: readonly KnowledgeRealtimeEvent[] = ['INSERT', 'UPDATE']
/**
* Pure: the postgres_changes bindings the Knowledge screen may open. DELETE is
* intentionally never included. `ownerId` must already be validated by the
* caller because it is interpolated into the realtime filter.
*/
export function knowledgeRealtimeBindings(ownerId?: string): KnowledgeRealtimeBinding[] {
return SUBSCRIBED_EVENTS.map((event) => ({
event,
schema: 'public',
table: 'knowledge_documents',
...(ownerId === undefined ? {} : { filter: `user_id=eq.${ownerId}` }),
}))
}
/** Port: whether the app is in the foreground, and changes to that state. */
export interface ForegroundStatePort {
isActive: () => boolean
onChange: (listener: (active: boolean) => void) => { remove: () => void }
}
/** Port: interval scheduling, injectable for tests. */
export interface IntervalPort {
set: (callback: () => void, ms: number) => ReturnType<typeof setInterval>
clear: (handle: ReturnType<typeof setInterval>) => void
}
export interface ForegroundReconciliationDeps {
foreground?: ForegroundStatePort
interval?: IntervalPort
intervalMs?: number
}
export interface ForegroundReconciliation {
stop: () => void
}
export const reactNativeForegroundState: ForegroundStatePort = {
isActive: () => AppState.currentState === 'active',
onChange: (listener) => {
const subscription = AppState.addEventListener('change', (state) => {
listener(state === 'active')
})
return { remove: () => subscription.remove() }
},
}
const systemInterval: IntervalPort = {
set: (callback, ms) => setInterval(callback, ms),
clear: (handle) => clearInterval(handle),
}
/**
* Calls `onReconcile` periodically while the app is in the foreground and once
* whenever it returns to the foreground, so remote deletions are picked up
* without a DELETE realtime subscription.
*/
export function startForegroundReconciliation(
onReconcile: () => void,
deps: ForegroundReconciliationDeps = {},
): ForegroundReconciliation {
const foreground = deps.foreground ?? reactNativeForegroundState
const interval = deps.interval ?? systemInterval
const intervalMs = deps.intervalMs ?? KNOWLEDGE_DELETION_RECONCILE_INTERVAL_MS
let stopped = false
let active = foreground.isActive()
const stateSubscription = foreground.onChange((nextActive) => {
const resumed = nextActive && !active
active = nextActive
if (resumed && !stopped) onReconcile()
})
const handle = interval.set(() => {
if (active && !stopped) onReconcile()
}, intervalMs)
return {
stop: () => {
if (stopped) return
stopped = true
interval.clear(handle)
stateSubscription.remove()
},
}
}

View file

@ -7,6 +7,11 @@ import type {
import { SUPABASE_URL } from '@d3ro/core/supabase-config'
import { supabase } from '../../lib/supabase'
import { createUuidV4 } from '../../lib/random-id'
import {
type ForegroundReconciliationDeps,
knowledgeRealtimeBindings,
startForegroundReconciliation,
} from './knowledge-realtime'
export type MobileKnowledgeFileType = Extract<KnowledgeFileType, 'txt' | 'md'>
@ -607,31 +612,40 @@ export async function searchKnowledge(
}
}
export interface KnowledgeDocumentSubscriptionOptions {
/**
* Owner whose documents the list shows. When given, INSERT/UPDATE events are
* narrowed to that owner so teammates' shared-document writes do not trigger
* list refetches for a list that only shows owned documents.
*/
userId?: string
/** Injectable foreground/interval ports for deletion reconciliation. */
reconciliation?: ForegroundReconciliationDeps
}
/**
* Live updates for the Knowledge list. Only INSERT/UPDATE are subscribed:
* Postgres Changes cannot filter DELETE events and does not apply RLS to them,
* so a DELETE subscription would leak other accounts' document ids and fan out
* a refetch to every client on every deletion. Deletions are reconciled by
* foreground polling instead (same policy as use-history-sync.ts).
*/
export function subscribeToKnowledgeDocuments(
onChanged: () => void,
onStatus: (connected: boolean) => void,
options: KnowledgeDocumentSubscriptionOptions = {},
): KnowledgeDocumentSubscription {
const channel: RealtimeChannel = supabase
.channel(`mobile-knowledge-${createUuidV4()}`)
.on('postgres_changes', {
event: 'INSERT',
schema: 'public',
table: 'knowledge_documents',
}, onChanged)
.on('postgres_changes', {
event: 'UPDATE',
schema: 'public',
table: 'knowledge_documents',
}, onChanged)
.on('postgres_changes', {
event: 'DELETE',
schema: 'public',
table: 'knowledge_documents',
}, onChanged)
.subscribe((status) => onStatus(status === 'SUBSCRIBED'))
if (options.userId !== undefined) requireUuid(options.userId, 'knowledge owner id')
let channel: RealtimeChannel = supabase.channel(`mobile-knowledge-${createUuidV4()}`)
for (const binding of knowledgeRealtimeBindings(options.userId)) {
channel = channel.on('postgres_changes', binding, onChanged)
}
channel = channel.subscribe((status) => onStatus(status === 'SUBSCRIBED'))
const reconciliation = startForegroundReconciliation(onChanged, options.reconciliation)
return {
unsubscribe: async () => {
reconciliation.stop()
await supabase.removeChannel(channel)
},
}

View file

@ -0,0 +1,39 @@
import type { Meeting, MeetingStatus } from '@d3ro/api-client'
/**
* Meeting states the server may answer with for each recording lifecycle
* transition. Pure policy: the RPC wrappers in meetings-service only decide
* whether a returned row confirms the transition they asked for.
*
* A capture that ends without a new transcript (cancelled, failed after
* upload, discarded while queued) leaves a meeting that still holds content
* from an earlier recording 'completed' (migrations 20260929002700,
* 20260929040000); only a meeting without earlier content becomes 'error'.
*/
export type MeetingRecordingTransition =
| 'recording-started'
| 'recording-queued'
| 'processing-completed'
| 'capture-abandoned'
const EXPECTED_STATES: Readonly<Record<MeetingRecordingTransition, readonly MeetingStatus[]>> = {
'recording-started': ['recording'],
'recording-queued': ['processing'],
'processing-completed': ['completed'],
'capture-abandoned': ['error', 'completed'],
}
export function expectedMeetingStates(
transition: MeetingRecordingTransition,
): readonly MeetingStatus[] {
return EXPECTED_STATES[transition]
}
/** True when `meeting` belongs to `userId` and is in a state `transition` may produce. */
export function confirmsMeetingTransition(
meeting: Pick<Meeting, 'user_id' | 'status'>,
userId: string,
transition: MeetingRecordingTransition,
): boolean {
return meeting.user_id === userId && EXPECTED_STATES[transition].includes(meeting.status)
}

View file

@ -17,6 +17,10 @@ import {
type LinkedAudioRef,
type LinkedAudioStore,
} from '../audio/linked-audio-cleanup'
import {
confirmsMeetingTransition,
type MeetingRecordingTransition,
} from './meeting-recording-state-policy'
export interface MeetingListOptions {
userId: string
@ -267,6 +271,18 @@ function assertMeetingRow(row: Meeting | null, expectedId?: string): Meeting {
return row
}
function assertMeetingTransition(
meeting: Meeting,
userId: string,
transition: MeetingRecordingTransition,
message: string,
): Meeting {
if (!confirmsMeetingTransition(meeting, userId, transition)) {
throw new MeetingServiceError('server', message)
}
return meeting
}
function assertProcessingJobRow(row: ProcessingJob | null): ProcessingJob {
if (row === null) {
throw new MeetingServiceError('server', 'Processing job was not returned')
@ -801,11 +817,12 @@ export async function beginMeetingRecording(
'mobile_begin_meeting_recording',
{ p_meeting_id: meetingId },
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'recording') {
throw new MeetingServiceError('server', 'Meeting recording state was not confirmed')
}
return meeting
return assertMeetingTransition(
assertMeetingRow(row, meetingId),
userId,
'recording-started',
'Meeting recording state was not confirmed',
)
} catch (error) {
throw toMeetingServiceError(error)
}
@ -859,11 +876,12 @@ export async function queueMeetingRecording(
'mobile_queue_meeting_recording',
{ p_meeting_id: meetingId, p_duration_ms: Math.round(durationMs) },
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'processing') {
throw new MeetingServiceError('server', 'Queued meeting state was not confirmed')
}
return meeting
return assertMeetingTransition(
assertMeetingRow(row, meetingId),
userId,
'recording-queued',
'Queued meeting state was not confirmed',
)
} catch (error) {
throw toMeetingServiceError(error)
}
@ -899,11 +917,12 @@ export async function completeMeetingProcessing(
p_stt_latency_ms: Math.round(result.sttLatencyMs),
},
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'completed') {
throw new MeetingServiceError('server', 'Meeting completion was not confirmed')
}
return meeting
return assertMeetingTransition(
assertMeetingRow(row, meetingId),
userId,
'processing-completed',
'Meeting completion was not confirmed',
)
} catch (error) {
throw toMeetingServiceError(error)
}
@ -952,11 +971,13 @@ export async function cancelMeetingRecording(
'mobile_cancel_meeting_recording',
{ p_meeting_id: meetingId },
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'error') {
throw new MeetingServiceError('server', 'Meeting cancellation was not confirmed')
}
return meeting
// A re-record of a meeting with earlier content returns to 'completed'.
return assertMeetingTransition(
assertMeetingRow(row, meetingId),
userId,
'capture-abandoned',
'Meeting cancellation was not confirmed',
)
} catch (error) {
throw toMeetingServiceError(error)
}
@ -978,11 +999,13 @@ export async function failMeetingRecording(
'mobile_fail_meeting_recording',
{ p_meeting_id: meetingId, p_error_message: safeMessage },
)
const meeting = assertMeetingRow(row, meetingId)
if (meeting.user_id !== userId || meeting.status !== 'error') {
throw new MeetingServiceError('server', 'Meeting error state was not confirmed')
}
return meeting
// A re-record of a meeting with earlier content returns to 'completed'.
return assertMeetingTransition(
assertMeetingRow(row, meetingId),
userId,
'capture-abandoned',
'Meeting error state was not confirmed',
)
} catch (error) {
throw toMeetingServiceError(error)
}

View file

@ -77,6 +77,12 @@ const listeners = new Set<(event: DurableQueueEvent) => void>();
const runningControllers = new Map<string, AbortController>();
const runningItemPromises = new Map<string, Promise<AudioPipelineResult>>();
const discardedItems = new Set<string>();
/**
* Owners whose session was lost while their items stay parked on the device.
* Timer-driven passes skip them so a signed-out owner's retries are not spent
* on certain auth failures; resumeQueuedAudioForUser (sign-in) lifts it.
*/
const suspendedUsers = new Set<string>();
function emit(event: DurableQueueEvent): void {
for (const listener of listeners) listener(event);
@ -458,11 +464,13 @@ export async function processQueuedAudioNow(
* overwritten from a stale snapshot.
*/
async function runResumePass(userId: string): Promise<void> {
if (suspendedUsers.has(userId)) return;
const order = (await readQueue())
.filter(item => item.userId === userId)
.sort((left, right) => left.createdAtMs - right.createdAtMs)
.map(item => item.id);
for (const itemId of order) {
if (suspendedUsers.has(userId)) return;
const item = await repository.get(itemId);
if (item === undefined || item.userId !== userId) continue;
if (item.status === 'terminal') continue;
@ -493,6 +501,7 @@ async function runResumePass(userId: string): Promise<void> {
* pass after it, and the returned promise settles when that pass is done.
*/
export function resumeQueuedAudioForUser(userId: string): Promise<void> {
suspendedUsers.delete(userId);
return scheduler.requestRun(userId);
}
@ -545,6 +554,28 @@ export async function clearQueuedAudioForUser(userId: string): Promise<void> {
if (firstRemovalError !== null) throw firstRemovalError;
}
/**
* Involuntary session loss: keeps every item of `ownerUserId` (files and
* entries, whatever their status) and suspends its retries until that owner
* signs back in; items of any other account are discarded as usual.
*/
export async function retainQueuedAudioOnlyForUser(ownerUserId: string): Promise<void> {
suspendedUsers.add(ownerUserId);
scheduler.cancel(ownerUserId);
const otherUserIds = [...new Set((await readQueue())
.map(item => item.userId)
.filter(userId => userId !== ownerUserId))];
let firstError: unknown = null;
for (const userId of otherUserIds) {
try {
await clearQueuedAudioForUser(userId);
} catch (error) {
firstError ??= error;
}
}
if (firstError !== null) throw firstError;
}
export async function clearAllQueuedAudio(): Promise<void> {
const userIds = [...new Set((await readQueue()).map(item => item.userId))];
let firstError: unknown = null;

View file

@ -1,15 +1,16 @@
import { SUPABASE_ANON_KEY, SUPABASE_URL } from '@d3ro/core/supabase-config'
import { FileSystem } from 'react-native-file-access'
import type { RecordedAudio } from '../../lib/audio-recorder'
import { createLogger } from '../../lib/logger'
import {
AudioPipelineError,
type LocalAudioInput,
type MobileTranscriptionResult,
} from '../import/audio-import-types'
import { decodeBase64 } from '../import/audio-validation'
import { createMultipartAudioBody } from '../import/multipart-audio'
import { prepareRecordedAudio } from '../import/recorded-audio-input'
import { transcribeAudioLocally } from '../import/local-whisper-transcription'
import { requireSttAccessToken, transcribeCloud } from '../import/stt-cloud-client'
import { transcribeWithLocalFallback } from '../import/stt-engine'
interface TalkTranscriptionOptions {
accessToken: string
@ -20,32 +21,7 @@ interface TalkTranscriptionOptions {
}
const DEFAULT_STT_TIMEOUT_MS = 120_000
interface LinkedDeadline {
signal: AbortSignal
didTimeOut: () => boolean
dispose: () => void
}
function linkedDeadline(parent: AbortSignal, timeoutMs: number): LinkedDeadline {
const controller = new AbortController()
let timedOut = false
const abortFromParent = (): void => controller.abort()
parent.addEventListener('abort', abortFromParent, { once: true })
if (parent.aborted) controller.abort()
const timer = setTimeout(() => {
timedOut = true
controller.abort()
}, timeoutMs)
return {
signal: controller.signal,
didTimeOut: () => timedOut,
dispose: () => {
clearTimeout(timer)
parent.removeEventListener('abort', abortFromParent)
},
}
}
const log = createLogger('Talk')
function ensureNotCancelled(signal: AbortSignal): void {
if (signal.aborted) {
@ -53,38 +29,8 @@ function ensureNotCancelled(signal: AbortSignal): void {
}
}
function parseCloudResult(value: unknown, latencyMs: number): MobileTranscriptionResult {
if (typeof value !== 'object' || value === null) {
throw new AudioPipelineError('transcription', 'The speech service returned invalid data')
}
const body = value as Record<string, unknown>
if (
typeof body.transcript !== 'string'
|| body.transcript.trim().length === 0
|| typeof body.language_code !== 'string'
|| body.language_code.trim().length === 0
|| typeof body.duration_seconds !== 'number'
|| !Number.isFinite(body.duration_seconds)
|| body.duration_seconds < 0
|| typeof body.provider !== 'string'
|| body.provider.trim().length === 0
) {
throw new AudioPipelineError('transcription', 'The speech service returned incomplete data')
}
const confidence = typeof body.confidence === 'number' && Number.isFinite(body.confidence)
? Math.max(0, Math.min(1, body.confidence))
: null
return {
text: body.transcript.trim(),
confidence,
language: body.language_code.trim(),
durationSeconds: body.duration_seconds,
provider: body.provider.trim(),
latencyMs,
}
}
// Talk reads the recording itself so the "file changed under us" size check
// stays next to the read, before any byte is sent to the cloud.
async function readBytes(input: LocalAudioInput): Promise<Uint8Array> {
try {
const bytes = decodeBase64(await FileSystem.readFile(input.path, 'base64'))
@ -98,70 +44,22 @@ async function readBytes(input: LocalAudioInput): Promise<Uint8Array> {
}
}
async function transcribeCloud(
async function transcribeTalkCloud(
input: LocalAudioInput,
accessToken: string,
languageCode: string,
signal: AbortSignal,
timeoutMs: number,
options: TalkTranscriptionOptions,
): Promise<MobileTranscriptionResult> {
const token = accessToken.trim()
if (token.length === 0) throw new AudioPipelineError('auth', 'Sign in to transcribe speech')
ensureNotCancelled(signal)
const accessToken = requireSttAccessToken(options.accessToken)
ensureNotCancelled(options.signal)
const bytes = await readBytes(input)
ensureNotCancelled(signal)
const multipart = createMultipartAudioBody(input, bytes, languageCode)
const deadline = linkedDeadline(signal, timeoutMs)
const startedAt = Date.now()
try {
const response = await fetch(`${SUPABASE_URL}/functions/v1/stt-proxy`, {
method: 'POST',
headers: {
apikey: SUPABASE_ANON_KEY,
Authorization: `Bearer ${token}`,
'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`,
},
body: multipart.body,
signal: deadline.signal,
})
if (!response.ok) {
if (response.status === 401 || response.status === 403) {
throw new AudioPipelineError('auth', 'The speech session is no longer authorized')
}
if (response.status === 413) {
throw new AudioPipelineError('file-too-large', 'The recording is too large')
}
if (response.status === 429) {
throw new AudioPipelineError('quota', 'The speech transcription quota is exhausted')
}
if (response.status === 502 || response.status === 503 || response.status === 504) {
// 진단: 엣지함수 본문(stt_provider_unavailable=시크릿 없음 vs
// stt_upstream_failed=제공자 실패)을 로그에서 구분할 수 있게 남긴다.
const bodyText = await response.text().catch(() => '')
throw new AudioPipelineError(
'provider-unavailable',
`Cloud STT unavailable (HTTP ${response.status}: ${bodyText.replace(/\s+/g, ' ').slice(0, 160)})`,
)
}
throw new AudioPipelineError(
'transcription',
`Speech transcription failed with status ${response.status}`,
)
}
return parseCloudResult(await response.json(), Date.now() - startedAt)
} catch (error) {
if (error instanceof AudioPipelineError) throw error
if (signal.aborted) {
throw new AudioPipelineError('cancelled', 'Talk transcription was cancelled', error)
}
if (deadline.didTimeOut()) {
throw new AudioPipelineError('provider-unavailable', 'Cloud speech recognition timed out', error)
}
throw new AudioPipelineError('transcription', 'Cloud speech recognition could not be reached', error)
} finally {
deadline.dispose()
}
ensureNotCancelled(options.signal)
return transcribeCloud({
input,
bytes,
accessToken,
languageCode: options.languageCode,
signal: options.signal,
timeoutMs: options.timeoutMs ?? DEFAULT_STT_TIMEOUT_MS,
})
}
export async function transcribeTalkRecording(
@ -172,59 +70,18 @@ export async function transcribeTalkRecording(
let primaryError: unknown = null
try {
try {
return await transcribeCloud(
input,
options.accessToken,
options.languageCode,
options.signal,
options.timeoutMs ?? DEFAULT_STT_TIMEOUT_MS,
)
} catch (cloudError) {
if (
!(cloudError instanceof AudioPipelineError)
|| cloudError.code === 'auth'
|| cloudError.code === 'cancelled'
|| cloudError.code === 'quota'
|| cloudError.code === 'file-too-large'
) {
throw cloudError
}
return await transcribeWithLocalFallback({
input,
languageCode: options.languageCode,
signal: options.signal,
cloud: () => transcribeTalkCloud(input, options),
local: transcribeAudioLocally,
// 진단: 클라우드가 실패해 기기 whisper로 폴백하는 이유를 남긴다.
console.warn(
'[Talk] cloud STT failed, falling back to on-device whisper:',
`${(cloudError as AudioPipelineError).code}: ${cloudError.message}`,
)
try {
const result = await transcribeAudioLocally(
input,
options.languageCode,
options.signal,
)
if (result.text.trim().length === 0) {
// 기기 whisper는 실행됐지만 한국어 등을 인식하지 못한 경우 —
// "모두 실패"가 아니다. 클라우드 실패 원인도 원인 체인에 남긴다.
throw new AudioPipelineError(
'no-speech',
'No speech was detected in the recording',
cloudError,
)
}
return result
} catch (localError) {
if (localError instanceof AudioPipelineError && localError.code === 'cancelled') {
throw localError
}
throw new AudioPipelineError(
localError instanceof AudioPipelineError ? localError.code : 'transcription',
localError instanceof Error
? localError.message
: 'Cloud and on-device speech recognition both failed',
{ cloudError, localError },
)
}
}
onFallback: cloudError => log.warn(
'cloud STT failed, falling back to on-device whisper:',
`${cloudError.code}: ${cloudError.message}`,
),
})
} catch (error) {
primaryError = error
throw error

View file

@ -2,6 +2,7 @@ import type { RealtimeChannel } from '@supabase/supabase-js'
import type { Meeting, Team, TeamRole } from '@d3ro/api-client'
import { PUBLIC_SITE_ORIGIN, SITE_URLS } from '@d3ro/core/web-urls'
import { supabase } from '../../lib/supabase'
import { invokeEdgeFunction, type EdgeFunctionFailure } from '../../lib/edge-functions'
export interface TeamSummary extends Team {
role: TeamRole
@ -407,23 +408,26 @@ async function invokeRpc(name: string, params: Record<string, unknown>): Promise
}
}
async function readFunctionFailure(error: unknown): Promise<TeamServiceError> {
const context = (error as { context?: unknown })?.context
if (context instanceof Response) {
try {
const payload: unknown = await context.clone().json()
if (isRecord(payload)) {
const code = typeof payload.error === 'string' ? payload.error : ''
const message = typeof payload.message === 'string'
? payload.message
: code || `Team function returned ${context.status}`
return toTeamServiceError({ code, message })
}
} catch {
return toTeamServiceError({ message: `Team function returned ${context.status}` })
/**
* Team Edge Functions put the machine code in `error` and a human string in
* `message`. The transport decoding lives in lib/edge-functions; this keeps
* only the team-specific field selection and fallbacks.
*/
function readFunctionFailure(failure: EdgeFunctionFailure): TeamServiceError {
if (failure.status !== null && failure.body !== null) {
if (!failure.body.readable) {
return toTeamServiceError({ message: `Team function returned ${failure.status}` })
}
const payload = failure.body.payload
if (isRecord(payload)) {
const code = typeof payload.error === 'string' ? payload.error : ''
const message = typeof payload.message === 'string'
? payload.message
: code || `Team function returned ${failure.status}`
return toTeamServiceError({ code, message })
}
}
return toTeamServiceError(error)
return toTeamServiceError(failure.cause)
}
export async function listTeams(userId: string): Promise<TeamSummary[]> {
@ -705,10 +709,9 @@ export async function createTeamInvite(
throw new TeamServiceError('validation', 'Invite role is invalid')
}
try {
const { data, error } = await supabase.functions.invoke('team-invite', {
body: { team_id: teamId, email, role },
})
if (error !== null) throw await readFunctionFailure(error)
const result = await invokeEdgeFunction('team-invite', { team_id: teamId, email, role })
if (!result.ok) throw readFunctionFailure(result)
const data = result.data
if (!isRecord(data)) {
throw new TeamServiceError('invalid-response', 'Invite response is not an object')
}
@ -746,10 +749,9 @@ export async function cancelTeamInvite(teamId: string, inviteId: string): Promis
export async function acceptTeamInvite(tokenValue: string): Promise<TeamAcceptResult> {
const token = normalizeInviteToken(tokenValue)
try {
const { data, error } = await supabase.functions.invoke('team-accept', {
body: { token },
})
if (error !== null) throw await readFunctionFailure(error)
const result = await invokeEdgeFunction('team-accept', { token })
if (!result.ok) throw readFunctionFailure(result)
const data = result.data
if (!isRecord(data)) {
throw new TeamServiceError('invalid-response', 'Accept response is not an object')
}

View file

@ -1,11 +1,21 @@
import { clearAllActionHistories } from '../features/actions/action-service'
import { clearAllHistoryCaches } from '../features/history/history-cache'
import { deleteNativePushRegistration } from '../features/notifications/notification-native'
import { clearAllQueuedAudio } from '../features/recording/durable-processing-queue'
import {
clearAllQueuedAudio,
retainQueuedAudioOnlyForUser,
} from '../features/recording/durable-processing-queue'
import { clearEveryGenerationIdempotencyKey } from '../features/templates'
import { audioRecorder } from './audio-recorder'
import {
DISCARD_UNSYNCED_WORK,
unsyncedWorkDispositionKey,
type UnsyncedWorkDisposition,
} from './auth-transition-policy'
import { clearAllEntitlementCaches } from './entitlement-context'
import { clearAllUserPreferenceCaches } from './preferences-context'
import { stopLiveCaptureKeepingFile } from './retain-live-capture'
import { retainedAccountWork } from './retained-account-work'
export class AccountLocalDataPurgeError extends Error {
readonly code = 'account_local_data_purge_failed'
@ -16,34 +26,88 @@ export class AccountLocalDataPurgeError extends Error {
}
}
let purgeInFlight: Promise<void> | null = null
type PurgeTask = () => Promise<void>
/** Account-scoped caches: always removed, they can be rebuilt from the server. */
const ACCOUNT_CACHE_TASKS: readonly PurgeTask[] = [
clearAllHistoryCaches,
clearAllEntitlementCaches,
clearAllUserPreferenceCaches,
clearAllActionHistories,
clearEveryGenerationIdempotencyKey,
deleteNativePushRegistration,
]
/**
* Removes every account-scoped local artifact before an auth boundary opens.
* Unsynced user-owned work (the live capture and durable-queue recordings) is
* the only copy of that audio. It is deleted only for a discard boundary; on a
* retain boundary the microphone is stopped, the owner's files are kept and
* the owner is recorded so a different account can still never see them.
*/
function unsyncedWorkTasks(disposition: UnsyncedWorkDisposition): PurgeTask[] {
if (disposition.kind === 'discard') {
return [
() => audioRecorder.cancel(),
clearAllQueuedAudio,
() => retainedAccountWork.release(),
]
}
const owner = disposition.ownerUserId
return [
() => retainedAccountWork.retain(owner),
() => stopLiveCaptureKeepingFile(audioRecorder),
() => retainQueuedAudioOnlyForUser(owner),
]
}
function startTask(task: PurgeTask): Promise<void> {
try {
return Promise.resolve(task())
} catch (error) {
return Promise.reject(error)
}
}
async function runPurge(disposition: UnsyncedWorkDisposition): Promise<void> {
const results = await Promise.allSettled(
[...ACCOUNT_CACHE_TASKS, ...unsyncedWorkTasks(disposition)].map(startTask),
)
if (results.some((result) => result.status === 'rejected')) {
throw new AccountLocalDataPurgeError()
}
}
interface PurgeRun {
key: string
promise: Promise<void>
}
let purgeInFlight: PurgeRun | null = null
/**
* Removes account-scoped local artifacts before an auth boundary opens.
* All tasks are allowed to settle so an early failure cannot skip deletion of
* unrelated caches or raw audio; callers receive only a stable public code.
*
* Concurrent calls with the same disposition share one run; a call with a
* different disposition (for example a sign-out immediately replaced by
* another account) runs after the current one so its stricter cleanup is
* never swallowed by the earlier, weaker one.
*/
export function purgeAllAccountLocalData(): Promise<void> {
if (purgeInFlight !== null) return purgeInFlight
export function purgeAllAccountLocalData(
unsyncedWork: UnsyncedWorkDisposition = DISCARD_UNSYNCED_WORK,
): Promise<void> {
const key = unsyncedWorkDispositionKey(unsyncedWork)
const previous = purgeInFlight
if (previous !== null && previous.key === key) return previous.promise
const operation = (async (): Promise<void> => {
const results = await Promise.allSettled([
clearAllHistoryCaches(),
clearAllEntitlementCaches(),
clearAllUserPreferenceCaches(),
audioRecorder.cancel(),
clearAllQueuedAudio(),
clearAllActionHistories(),
clearEveryGenerationIdempotencyKey(),
deleteNativePushRegistration(),
])
if (results.some((result) => result.status === 'rejected')) {
throw new AccountLocalDataPurgeError()
}
})().finally(() => {
if (purgeInFlight === operation) purgeInFlight = null
const run = previous === null
? runPurge(unsyncedWork)
: previous.promise.catch(() => undefined).then(() => runPurge(unsyncedWork))
const promise: Promise<void> = run.finally(() => {
if (purgeInFlight?.promise === promise) purgeInFlight = null
})
purgeInFlight = operation
return operation
purgeInFlight = { key, promise }
return promise
}

View file

@ -14,6 +14,8 @@ import { supabase, isSupabaseConfigured } from './supabase'
import { completeAuthRedirect, isAuthRedirectUrl } from './auth-redirect'
import { clearAllSecureAuthStorage } from './secure-auth-storage'
import { purgeAllAccountLocalData } from './account-local-data'
import { DISCARD_UNSYNCED_WORK, planAuthTransition } from './auth-transition-policy'
import { retainedAccountWork } from './retained-account-work'
export type AuthPrivacyCleanupState = 'ready' | 'purging' | 'failed'
@ -106,12 +108,19 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
): Promise<boolean> => {
const generation = ++transitionGenerationRef.current
const previousUserId = committedSessionRef.current?.user.id ?? null
const nextUserId = transition.session?.user.id ?? null
const needsPurge = transition.forcePurge
|| privacyCleanupStateRef.current !== 'ready'
|| (previousUserId !== null && previousUserId !== nextUserId)
const plan = planAuthTransition({
previousUserId,
nextUserId: transition.session?.user.id ?? null,
retainedOwnerUserId: retainedAccountWork.current(),
forcePurge: transition.forcePurge,
cleanupPending: privacyCleanupStateRef.current !== 'ready',
explicit: transition.explicit,
})
if (!needsPurge) {
if (!plan.purge) {
// The owner of work kept by an earlier session loss is back: the queue
// hook and Record screen resume it, so the retention marker can go.
if (plan.releaseRetainedWork) void retainedAccountWork.release().catch(() => undefined)
pendingTransitionRef.current = null
commitSession(transition.session, transition.event)
updatePrivacyCleanupState('ready')
@ -122,7 +131,7 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
updatePrivacyCleanupState('purging')
try {
await purgeAllAccountLocalData()
await purgeAllAccountLocalData(plan.unsyncedWork)
if (generation !== transitionGenerationRef.current) return false
if (transition.clearSecureAuth) {
@ -130,21 +139,31 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
// A SIGNED_OUT notification can race with a direct A -> B account
// replacement. Preserve a newer different-account session that the
// auth client has already committed to secure storage.
let replacementSession: Session | null = null
try {
const { data } = await supabase.auth.getSession()
if (generation !== transitionGenerationRef.current) return false
const latestSession = data.session
const latestUserId = latestSession?.user.id ?? null
if (latestSession !== null && latestUserId !== previousUserId) {
pendingTransitionRef.current = null
commitSession(latestSession, 'SIGNED_IN')
updatePrivacyCleanupState('ready')
return true
replacementSession = latestSession
}
} catch {
// If the auth client cannot prove that a newer session exists,
// fail closed by removing the signed-out account's secure data.
}
if (replacementSession !== null) {
if (plan.unsyncedWork.kind === 'retain') {
// Work kept for the signed-out account must not stay on a
// device that another account has already taken over.
await purgeAllAccountLocalData(DISCARD_UNSYNCED_WORK)
if (generation !== transitionGenerationRef.current) return false
}
pendingTransitionRef.current = null
commitSession(replacementSession, 'SIGNED_IN')
updatePrivacyCleanupState('ready')
return true
}
}
try {
@ -228,6 +247,9 @@ export function AuthProvider({ children }: { children: ReactNode }): React.React
}
try {
// The retained-work owner must be known before the first boundary
// decides between keeping and discarding unsynced recordings.
await retainedAccountWork.load().catch(() => null)
const initialUrl = await Linking.getInitialURL()
if (initialUrl !== null) await handleUrl(initialUrl)
const { data: { session: restoredSession }, error } = await supabase.auth.getSession()

View file

@ -6,22 +6,19 @@ export const AUTH_REDIRECT_URL = 'd3ro-voice://auth-callback'
export type AuthRedirectType = 'recovery' | 'signed-in' | 'cancelled' | 'ignored'
/**
* The only auth operation a callback may drive. A session is created solely by
* a PKCE code exchange, which the auth server binds to the code verifier this
* device stored when it started the flow.
*/
type AuthRedirectOperations = {
exchangeCodeForSession: typeof supabase.auth.exchangeCodeForSession
setSession: typeof supabase.auth.setSession
}
type ParsedAuthRedirect =
| { kind: 'ignored' }
| { kind: 'cancelled' }
| { kind: 'code'; code: string; recovery: boolean; replayKey: string }
| {
kind: 'tokens'
accessToken: string
refreshToken: string
recovery: boolean
replayKey: string
}
export class AuthRedirectError extends Error {
readonly code: 'invalid_callback' | 'provider_error'
@ -46,8 +43,8 @@ function hasAuthMaterial(parsedUrl: URL): boolean {
|| fragment.has('error_description')
}
function replayFingerprint(kind: 'code' | 'tokens', secret: string): string {
return `${kind}:${bytesToHex(sha256(utf8ToBytes(secret)))}`
function replayFingerprint(secret: string): string {
return `code:${bytesToHex(sha256(utf8ToBytes(secret)))}`
}
export function isAuthRedirectUrl(url: string): boolean {
@ -89,34 +86,23 @@ function parseAuthRedirect(url: string): ParsedAuthRedirect {
throw new AuthRedirectError('provider_error')
}
const code = query.get('code')
const accessToken = fragment.get('access_token')
const refreshToken = fragment.get('refresh_token')
const hasCode = typeof code === 'string' && code.length > 0
const hasAccessToken = typeof accessToken === 'string' && accessToken.length > 0
const hasRefreshToken = typeof refreshToken === 'string' && refreshToken.length > 0
// Implicit-flow session material (#access_token/#refresh_token) is never
// accepted. The client runs every flow as PKCE, so a legitimate callback
// carries only ?code=. A bare token pair is bound to nothing this device
// started: any web page or app could open it and sign the device into
// another account (login CSRF), which would also trigger the account-switch
// purge of this user's local data.
if (fragment.has('access_token') || fragment.has('refresh_token')) {
throw new AuthRedirectError('invalid_callback')
}
if (hasCode && (hasAccessToken || hasRefreshToken)) {
throw new AuthRedirectError('invalid_callback')
}
if (hasAccessToken !== hasRefreshToken) {
throw new AuthRedirectError('invalid_callback')
}
if (hasCode) {
const code = query.get('code')
if (typeof code === 'string' && code.length > 0) {
return {
kind: 'code',
code,
recovery: query.get('type') === 'recovery',
replayKey: replayFingerprint('code', code),
}
}
if (hasAccessToken && hasRefreshToken) {
return {
kind: 'tokens',
accessToken,
refreshToken,
recovery: fragment.get('type') === 'recovery',
replayKey: replayFingerprint('tokens', `${accessToken}\u0000${refreshToken}`),
replayKey: replayFingerprint(code),
}
}
return { kind: 'ignored' }
@ -146,21 +132,12 @@ export function createAuthRedirectHandler(
if (existing !== undefined) return existing
const operation = (async (): Promise<AuthRedirectType> => {
if (parsed.kind === 'code') {
const { data, error } = await auth.exchangeCodeForSession(parsed.code)
if (error !== null) throw error
const redirectData = data as typeof data & { redirectType?: string | null }
return parsed.recovery || redirectData.redirectType === 'recovery'
? 'recovery'
: 'signed-in'
}
const { error } = await auth.setSession({
access_token: parsed.accessToken,
refresh_token: parsed.refreshToken,
})
const { data, error } = await auth.exchangeCodeForSession(parsed.code)
if (error !== null) throw error
return parsed.recovery ? 'recovery' : 'signed-in'
const redirectData = data as typeof data & { redirectType?: string | null }
return parsed.recovery || redirectData.redirectType === 'recovery'
? 'recovery'
: 'signed-in'
})()
inFlight.set(parsed.replayKey, operation)

View file

@ -0,0 +1,69 @@
/**
* Pure policy for the mobile auth privacy boundary: given who was signed in,
* who is signing in, and whose unsynced work is parked on the device, decide
* whether local account data must be purged and what happens to the user's
* unsynced recordings (durable-queue items and the live capture file).
*
* Caches are always purged when the boundary runs. Unsynced work is different:
* it is the only copy of the user's audio, and queue items are already keyed
* by their owner, so it is kept across an involuntary session loss (a revoked
* refresh token, a password change on another device, a cold boot without a
* session) and deleted only when another account takes the device or the user
* signs out on purpose.
*/
export type UnsyncedWorkDisposition =
| { readonly kind: 'discard' }
| { readonly kind: 'retain'; readonly ownerUserId: string }
export const DISCARD_UNSYNCED_WORK: UnsyncedWorkDisposition = Object.freeze({ kind: 'discard' })
export interface AuthTransitionFacts {
/** Account whose session is currently committed to the UI. */
previousUserId: string | null
/** Account the transition would commit, or null for a signed-out state. */
nextUserId: string | null
/** Account whose unsynced work was kept by an earlier session loss. */
retainedOwnerUserId: string | null
/** The caller requires the boundary even for an unchanged account. */
forcePurge: boolean
/** An earlier boundary has not completed yet. */
cleanupPending: boolean
/** The user asked to sign out (logout, account deletion, device revoke). */
explicit: boolean
}
export type AuthTransitionPlan =
| {
readonly purge: false
/** The retained work's owner is back; the retention marker can go. */
readonly releaseRetainedWork: boolean
}
| { readonly purge: true; readonly unsyncedWork: UnsyncedWorkDisposition }
export function planAuthTransition(facts: AuthTransitionFacts): AuthTransitionPlan {
const workOwner = facts.previousUserId ?? facts.retainedOwnerUserId
const accountLeaves = facts.previousUserId !== null && facts.previousUserId !== facts.nextUserId
const otherAccountArrives = facts.nextUserId !== null
&& workOwner !== null
&& workOwner !== facts.nextUserId
if (!facts.forcePurge && !facts.cleanupPending && !accountLeaves && !otherAccountArrives) {
return {
purge: false,
releaseRetainedWork: facts.retainedOwnerUserId !== null
&& facts.retainedOwnerUserId === facts.nextUserId,
}
}
const involuntarySessionLoss = facts.nextUserId === null && !facts.explicit
if (involuntarySessionLoss && workOwner !== null) {
return { purge: true, unsyncedWork: { kind: 'retain', ownerUserId: workOwner } }
}
return { purge: true, unsyncedWork: DISCARD_UNSYNCED_WORK }
}
/** Stable key so concurrent boundaries with the same disposition share one run. */
export function unsyncedWorkDispositionKey(disposition: UnsyncedWorkDisposition): string {
return disposition.kind === 'retain' ? `retain:${disposition.ownerUserId}` : 'discard'
}

View file

@ -27,7 +27,12 @@ import {
type EntitlementSnapshot,
type MobileSubscriptionTier,
} from './entitlement-context'
import { supabase } from './supabase'
import {
invokeEdgeFunction,
readEdgeFunctionHttpFailure,
type EdgeFunctionFailure,
type EdgeFunctionHttpBody,
} from './edge-functions'
import { getMobileRuntimeConfig } from './native-config'
export const GOOGLE_PLAY_SUBSCRIPTION_IDS = [
@ -256,21 +261,37 @@ export async function restoreGooglePlayPurchaseSet(
return refreshed
}
async function functionErrorCode(candidate: unknown): Promise<string> {
if (candidate && typeof candidate === 'object' && 'context' in candidate) {
const context = (candidate as { context?: unknown }).context
if (context instanceof Response) {
try {
const body = await context.clone().json() as { error?: unknown }
if (typeof body.error === 'string') return body.error
} catch {
return 'purchase_verification_failed'
}
}
const DEFAULT_VERIFICATION_ERROR = 'purchase_verification_failed'
/**
* Billing Edge Functions put the machine code in `error`. Returns null when
* the body carries no usable code so callers fall back to the Error message.
*/
function billingCodeFromBody(body: EdgeFunctionHttpBody): string | null {
if (!body.readable || body.payload === null) return DEFAULT_VERIFICATION_ERROR
const payload = body.payload
if (typeof payload === 'object') {
const code = (payload as { error?: unknown }).error
if (typeof code === 'string') return code
}
return null
}
function errorMessageOrDefault(candidate: unknown): string {
return candidate instanceof Error && candidate.message
? candidate.message
: 'purchase_verification_failed'
: DEFAULT_VERIFICATION_ERROR
}
function billingFailureCode(failure: EdgeFunctionFailure): string {
const code = failure.body === null ? null : billingCodeFromBody(failure.body)
return code ?? errorMessageOrDefault(failure.cause)
}
async function functionErrorCode(candidate: unknown): Promise<string> {
const http = await readEdgeFunctionHttpFailure(candidate)
const code = http === null ? null : billingCodeFromBody(http.body)
return code ?? errorMessageOrDefault(candidate)
}
export function BillingProvider({ children }: { children: ReactNode }): React.ReactElement {
@ -306,14 +327,13 @@ export function BillingProvider({ children }: { children: ReactNode }): React.Re
setOperation(purchase.purchaseState === 'pending' ? 'pending' : 'verifying')
setErrorCode(null)
try {
const { data, error } = await supabase.functions.invoke<IapVerifyResult>('iap-verify', {
body: {
platform: 'google_play',
productId: purchase.productId,
purchaseToken: purchase.purchaseToken,
},
const result = await invokeEdgeFunction<IapVerifyResult>('iap-verify', {
platform: 'google_play',
productId: purchase.productId,
purchaseToken: purchase.purchaseToken,
})
if (error) throw new Error(await functionErrorCode(error))
if (!result.ok) throw new Error(billingFailureCode(result))
const data = result.data
const verifiedProduct = verifiedRestorableProduct(data, purchase.productId)
const verification = data && isRecord(data.verification) ? data.verification : null
const refreshed = await entitlement.refresh()
@ -509,14 +529,13 @@ export function BillingProvider({ children }: { children: ReactNode }): React.Re
restoredPurchases,
async (restoredPurchase) => {
if (userIdRef.current !== currentUserId) throw new Error('restore_session_changed')
const { data, error } = await supabase.functions.invoke<IapVerifyResult>('iap-verify', {
body: {
platform: 'google_play',
productId: restoredPurchase.productId,
purchaseToken: restoredPurchase.purchaseToken,
},
const result = await invokeEdgeFunction<IapVerifyResult>('iap-verify', {
platform: 'google_play',
productId: restoredPurchase.productId,
purchaseToken: restoredPurchase.purchaseToken,
})
if (error) throw new Error(await functionErrorCode(error))
if (!result.ok) throw new Error(billingFailureCode(result))
const data = result.data
return data
},
async () => {

View file

@ -0,0 +1,129 @@
// src/lib/edge-functions.ts — typed transport adapter for Supabase Edge Functions.
//
// functions-js returns `{ data: null, error: FunctionsHttpError }` for every
// non-2xx response and keeps the server's JSON body only inside
// `error.context` (a fetch Response). Callers must never inspect `data` to
// find a failure code. This adapter decodes that transport detail once so
// feature services only see a discriminated result.
//
// Edge functions do not share one error-body convention (team/billing put the
// code in `error`, account-delete puts it in `code` and a human string in
// `error`), so the adapter deliberately does NOT pick a code field. It hands
// back the parsed payload and each caller keeps its own field selection.
import { supabase } from './supabase'
/** Decoded body of a non-2xx Edge Function response. */
export type EdgeFunctionHttpBody =
| { readable: true; payload: unknown }
| { readable: false }
export interface EdgeFunctionHttpFailure {
status: number
body: EdgeFunctionHttpBody
}
export interface EdgeFunctionSuccess<T> {
ok: true
data: T | null
}
export interface EdgeFunctionFailure {
ok: false
/** HTTP status when the function responded; null for fetch/relay failures. */
status: number | null
/** Parsed response body when the function responded; null otherwise. */
body: EdgeFunctionHttpBody | null
/** Untranslated transport message. For diagnostics only, never for UI. */
message: string | null
/** The original SDK error (or thrown value), for callers that map it further. */
cause: unknown
}
export type EdgeFunctionResult<T> = EdgeFunctionSuccess<T> | EdgeFunctionFailure
/** Minimal port of the Supabase client this adapter depends on. */
export interface EdgeFunctionsClient {
functions: {
invoke(
name: string,
options: { body: Record<string, unknown> },
): Promise<{ data: unknown; error: unknown }>
}
}
/** Invocation port that feature services depend on instead of the SDK. */
export type EdgeFunctionInvoker = <T = unknown>(
name: string,
body: Record<string, unknown>,
) => Promise<EdgeFunctionResult<T>>
function errorMessage(error: unknown): string | null {
if (error instanceof Error) return error.message || null
if (typeof error === 'object' && error !== null && 'message' in error) {
const message = (error as { message?: unknown }).message
return typeof message === 'string' && message ? message : null
}
return null
}
/**
* Reads the HTTP response carried by a FunctionsHttpError. Returns null when
* the error has no Response context (fetch/relay errors, thrown values).
*/
export async function readEdgeFunctionHttpFailure(
error: unknown,
): Promise<EdgeFunctionHttpFailure | null> {
if (typeof error !== 'object' || error === null || !('context' in error)) return null
const context = (error as { context?: unknown }).context
if (!(context instanceof Response)) return null
try {
const payload: unknown = await context.clone().json()
return { status: context.status, body: { readable: true, payload } }
} catch {
return { status: context.status, body: { readable: false } }
}
}
/** Converts an SDK `{ data, error }` pair (or thrown value) into a typed result. */
export async function toEdgeFunctionResult<T>(
data: unknown,
error: unknown,
): Promise<EdgeFunctionResult<T>> {
if (error === null || error === undefined) {
return { ok: true, data: (data ?? null) as T | null }
}
const http = await readEdgeFunctionHttpFailure(error)
return {
ok: false,
status: http?.status ?? null,
body: http?.body ?? null,
message: errorMessage(error),
cause: error,
}
}
export async function invokeEdgeFunction<T = unknown>(
name: string,
body: Record<string, unknown>,
client: EdgeFunctionsClient = supabase,
): Promise<EdgeFunctionResult<T>> {
let response: { data: unknown; error: unknown }
try {
response = await client.functions.invoke(name, { body })
} catch (thrown) {
return toEdgeFunctionResult<T>(null, thrown ?? new Error('edge_function_invoke_failed'))
}
return toEdgeFunctionResult<T>(response.data, response.error)
}
/** Reads a string field from a decoded failure payload, if present. */
export function edgeFailureField(
failure: Pick<EdgeFunctionFailure, 'body'>,
field: string,
): string | null {
if (failure.body === null || !failure.body.readable) return null
const payload = failure.body.payload
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return null
const value = (payload as Record<string, unknown>)[field]
return typeof value === 'string' ? value : null
}

View file

@ -0,0 +1,26 @@
/**
* Minimal scoped logger for the React Native app. It is the single sink that
* talks to the JS console (Metro / logcat / Xcode), so feature code never
* calls `console.*` directly and the output format stays consistent.
*/
export interface Logger {
info: (message: string, ...details: unknown[]) => void;
warn: (message: string, ...details: unknown[]) => void;
error: (message: string, ...details: unknown[]) => void;
}
type LogLevel = keyof Logger;
type ConsoleSink = Pick<Console, LogLevel>;
export function createLogger(scope: string, sink: ConsoleSink = console): Logger {
const prefix = `[${scope}]`;
const write = (level: LogLevel) => (message: string, ...details: unknown[]): void => {
sink[level](`${prefix} ${message}`, ...details);
};
return {
info: write('info'),
warn: write('warn'),
error: write('error'),
};
}

View file

@ -0,0 +1,81 @@
// Production adapters for the preferences store ports (AsyncStorage + Supabase).
import AsyncStorage from '@react-native-async-storage/async-storage'
import type { SupabaseClient } from '@supabase/supabase-js'
import { supabase } from './supabase'
import {
USER_SETTINGS_COLUMNS,
type PreferencesRemote,
type PreferencesStorage,
} from './preferences-store'
const UNIQUE_VIOLATION = '23505'
export const asyncStoragePreferencesStorage: PreferencesStorage = {
multiGet: (keys) => AsyncStorage.multiGet(keys),
setItem: (key, value) => AsyncStorage.setItem(key, value),
multiSet: (pairs) => AsyncStorage.multiSet(pairs),
getAllKeys: () => AsyncStorage.getAllKeys(),
multiRemove: (keys) => AsyncStorage.multiRemove(keys),
}
export function createSupabasePreferencesRemote(
client: SupabaseClient = supabase,
): PreferencesRemote {
return {
async fetchRow(userId) {
const { data, error } = await client
.from('user_settings')
.select(USER_SETTINGS_COLUMNS)
.eq('user_id', userId)
.maybeSingle()
if (error) throw error
return data
},
async insertRow(row) {
const { data, error } = await client
.from('user_settings')
.insert(row)
.select(USER_SETTINGS_COLUMNS)
.single()
if (error?.code === UNIQUE_VIOLATION) return { status: 'duplicate' }
if (error) throw error
return { status: 'inserted', row: data }
},
async updateRowAtRevision(row, expectedRevision) {
const { data, error } = await client
.from('user_settings')
.update(row)
.eq('user_id', row.user_id)
.eq('revision', expectedRevision)
.select(USER_SETTINGS_COLUMNS)
.maybeSingle()
if (error) throw error
return data
},
}
}
/** Subscribes to UPDATEs of the user's settings row; returns an unsubscribe function. */
export function subscribeToUserSettingsChanges(
userId: string,
onRow: (row: unknown) => void,
client: SupabaseClient = supabase,
): () => void {
const channel = client
.channel(`mobile-user-settings-${userId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'user_settings',
filter: `user_id=eq.${userId}`,
},
(payload) => onRow(payload.new),
)
.subscribe()
return () => {
void client.removeChannel(channel)
}
}

View file

@ -1,4 +1,3 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
AppState,
useColorScheme,
@ -9,84 +8,49 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from 'react'
import type { ReactNode } from 'react'
import { useAuth } from './auth-context'
import { supabase } from './supabase'
import {
asyncStoragePreferencesStorage,
createSupabasePreferencesRemote,
subscribeToUserSettingsChanges,
} from './preferences-adapters'
import {
CURRENT_ONBOARDING_VERSION,
clearUserPreferenceCaches,
createPreferencesStore,
preferencesOwnerFor,
type MobilePreferences,
type MobilePreferencesPatch,
type PreferenceMutationResult,
type PreferencesErrorCode,
type PreferencesStore,
type PreferencesSyncStatus,
} from './preferences-store'
import {
getMobileThemePalette,
isMobileThemeMode,
resolveEffectiveTheme,
type EffectiveMobileTheme,
type MobileThemeMode,
type MobileThemePalette,
} from '../theme/mobile-theme'
export const CURRENT_ONBOARDING_VERSION = 1
const CACHE_SCHEMA_VERSION = 1
const INSTALLATION_CACHE_KEY = '@d3ro/mobile/preferences/installation-v1'
const USER_CACHE_PREFIX = '@d3ro/mobile/preferences/user-v1/'
const USER_SETTINGS_COLUMNS = [
'user_id',
'theme_mode',
'locale',
'haptic_enabled',
'auto_polish_enabled',
'preferred_stt_model',
'preferred_llm_model',
'onboarding_version',
'tutorial_completed_at',
'revision',
'updated_at',
].join(',')
export type SupportedMobileLocale = 'ko' | 'en'
export type PreferencesSyncStatus =
| 'loading'
| 'local'
| 'saving'
| 'synced'
| 'offline'
| 'error'
export type PreferencesErrorCode =
| 'CACHE_READ_FAILED'
| 'CACHE_WRITE_FAILED'
| 'SYNC_FAILED'
| 'SYNC_CONFLICT'
export interface MobilePreferences {
themeMode: MobileThemeMode
locale: SupportedMobileLocale
hapticEnabled: boolean
autoPolishEnabled: boolean
preferredSttModel: string | null
preferredLlmModel: string | null
onboardingVersion: number
tutorialCompletedAt: string | null
revision: number
updatedAt: string | null
}
export type MobilePreferencesPatch = Partial<Pick<
export {
CURRENT_ONBOARDING_VERSION,
applyPreferencesPatch,
normalizePreferences,
normalizePreferencesPatch,
} from './preferences-store'
export type {
MobilePreferences,
| 'themeMode'
| 'locale'
| 'hapticEnabled'
| 'autoPolishEnabled'
| 'preferredSttModel'
| 'preferredLlmModel'
| 'onboardingVersion'
| 'tutorialCompletedAt'
>>
export interface PreferenceMutationResult {
localSaved: boolean
serverSynced: boolean
}
MobilePreferencesPatch,
PreferenceMutationResult,
PreferencesErrorCode,
PreferencesSyncStatus,
SupportedMobileLocale,
} from './preferences-store'
interface PreferencesContextValue {
preferences: MobilePreferences
@ -104,758 +68,70 @@ interface PreferencesContextValue {
retrySync: () => Promise<void>
}
interface CacheEnvelope {
schemaVersion: number
preferences: MobilePreferences
pendingPatch: MobilePreferencesPatch
lastSyncedAt: string | null
}
interface SettingsRow {
user_id: string
theme_mode: unknown
locale: unknown
haptic_enabled: unknown
auto_polish_enabled: unknown
preferred_stt_model: unknown
preferred_llm_model: unknown
onboarding_version: unknown
tutorial_completed_at: unknown
revision: unknown
updated_at: unknown
}
interface ProviderSnapshot {
ownerKey: string
preferences: MobilePreferences
loading: boolean
syncStatus: PreferencesSyncStatus
errorCode: PreferencesErrorCode | null
lastSyncedAt: string | null
}
const DEFAULT_PREFERENCES: MobilePreferences = Object.freeze({
themeMode: 'system',
locale: 'ko',
hapticEnabled: true,
autoPolishEnabled: true,
preferredSttModel: null,
preferredLlmModel: null,
onboardingVersion: 0,
tutorialCompletedAt: null,
revision: 1,
updatedAt: null,
})
const PreferencesContext = createContext<PreferencesContextValue | null>(null)
export async function clearAllUserPreferenceCaches(): Promise<void> {
const keys = await AsyncStorage.getAllKeys()
const userKeys = keys.filter((key) => key.startsWith(USER_CACHE_PREFIX))
if (userKeys.length > 0) await AsyncStorage.multiRemove(userKeys)
export function clearAllUserPreferenceCaches(): Promise<void> {
return clearUserPreferenceCaches(asyncStoragePreferencesStorage)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizeLocale(value: unknown): SupportedMobileLocale {
return value === 'en' ? 'en' : 'ko'
}
function normalizeNullableString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0
? value.trim()
: null
}
function normalizeNonNegativeInteger(value: unknown, fallback: number): number {
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : fallback
}
function normalizePositiveInteger(value: unknown, fallback: number): number {
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback
}
function normalizeIsoDate(value: unknown): string | null {
if (typeof value !== 'string') return null
const timestamp = Date.parse(value)
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null
}
export function normalizePreferences(
value: unknown,
fallback: MobilePreferences = DEFAULT_PREFERENCES,
): MobilePreferences {
if (!isRecord(value)) return { ...fallback }
return {
themeMode: isMobileThemeMode(value.themeMode) ? value.themeMode : fallback.themeMode,
locale: normalizeLocale(value.locale ?? fallback.locale),
hapticEnabled: typeof value.hapticEnabled === 'boolean'
? value.hapticEnabled
: fallback.hapticEnabled,
autoPolishEnabled: typeof value.autoPolishEnabled === 'boolean'
? value.autoPolishEnabled
: fallback.autoPolishEnabled,
preferredSttModel: value.preferredSttModel === undefined
? fallback.preferredSttModel
: normalizeNullableString(value.preferredSttModel),
preferredLlmModel: value.preferredLlmModel === undefined
? fallback.preferredLlmModel
: normalizeNullableString(value.preferredLlmModel),
onboardingVersion: normalizeNonNegativeInteger(
value.onboardingVersion,
fallback.onboardingVersion,
),
tutorialCompletedAt: value.tutorialCompletedAt === undefined
? fallback.tutorialCompletedAt
: normalizeIsoDate(value.tutorialCompletedAt),
revision: normalizePositiveInteger(value.revision, fallback.revision),
updatedAt: value.updatedAt === undefined
? fallback.updatedAt
: normalizeIsoDate(value.updatedAt),
}
}
export function normalizePreferencesPatch(value: unknown): MobilePreferencesPatch {
if (!isRecord(value)) return {}
const patch: MobilePreferencesPatch = {}
if (isMobileThemeMode(value.themeMode)) patch.themeMode = value.themeMode
if (value.locale === 'ko' || value.locale === 'en') patch.locale = value.locale
if (typeof value.hapticEnabled === 'boolean') patch.hapticEnabled = value.hapticEnabled
if (typeof value.autoPolishEnabled === 'boolean') {
patch.autoPolishEnabled = value.autoPolishEnabled
}
if (value.preferredSttModel === null || typeof value.preferredSttModel === 'string') {
patch.preferredSttModel = normalizeNullableString(value.preferredSttModel)
}
if (value.preferredLlmModel === null || typeof value.preferredLlmModel === 'string') {
patch.preferredLlmModel = normalizeNullableString(value.preferredLlmModel)
}
if (value.onboardingVersion !== undefined) {
patch.onboardingVersion = normalizeNonNegativeInteger(value.onboardingVersion, 0)
}
if (value.tutorialCompletedAt === null || typeof value.tutorialCompletedAt === 'string') {
patch.tutorialCompletedAt = normalizeIsoDate(value.tutorialCompletedAt)
}
return patch
}
export function applyPreferencesPatch(
preferences: MobilePreferences,
patch: MobilePreferencesPatch,
): MobilePreferences {
return normalizePreferences({ ...preferences, ...normalizePreferencesPatch(patch) }, preferences)
}
function parseSettingsRow(value: unknown): MobilePreferences | null {
if (!isRecord(value) || typeof value.user_id !== 'string') return null
const row = value as unknown as SettingsRow
return normalizePreferences({
themeMode: row.theme_mode,
locale: row.locale,
hapticEnabled: row.haptic_enabled,
autoPolishEnabled: row.auto_polish_enabled,
preferredSttModel: row.preferred_stt_model,
preferredLlmModel: row.preferred_llm_model,
onboardingVersion: row.onboarding_version,
tutorialCompletedAt: row.tutorial_completed_at,
revision: row.revision,
updatedAt: row.updated_at,
function createDefaultStore(): PreferencesStore {
return createPreferencesStore({
storage: asyncStoragePreferencesStorage,
remote: createSupabasePreferencesRemote(),
})
}
function parseCacheEnvelope(raw: string | null): CacheEnvelope | null {
if (raw === null) return null
const parsed: unknown = JSON.parse(raw)
if (!isRecord(parsed) || parsed.schemaVersion !== CACHE_SCHEMA_VERSION) {
throw new Error('Unsupported mobile preferences cache schema')
}
return {
schemaVersion: CACHE_SCHEMA_VERSION,
preferences: normalizePreferences(parsed.preferences),
pendingPatch: normalizePreferencesPatch(parsed.pendingPatch),
lastSyncedAt: normalizeIsoDate(parsed.lastSyncedAt),
}
}
function createEnvelope(
preferences: MobilePreferences,
pendingPatch: MobilePreferencesPatch,
lastSyncedAt: string | null,
): CacheEnvelope {
return {
schemaVersion: CACHE_SCHEMA_VERSION,
preferences,
pendingPatch: normalizePreferencesPatch(pendingPatch),
lastSyncedAt,
}
}
function toInstallationPreferences(
preferences: MobilePreferences,
): MobilePreferences {
return {
...DEFAULT_PREFERENCES,
themeMode: preferences.themeMode,
locale: preferences.locale,
hapticEnabled: preferences.hapticEnabled,
onboardingVersion: preferences.onboardingVersion,
tutorialCompletedAt: preferences.tutorialCompletedAt,
}
}
function getUserCacheKey(userId: string): string {
return `${USER_CACHE_PREFIX}${userId}`
}
function serializeEnvelope(envelope: CacheEnvelope): string {
return JSON.stringify(envelope)
}
async function writeCaches(
userId: string | null,
preferences: MobilePreferences,
userPendingPatch: MobilePreferencesPatch,
lastSyncedAt: string | null,
installationPendingPatch: MobilePreferencesPatch = {},
): Promise<void> {
const installationEnvelope = createEnvelope(
toInstallationPreferences(preferences),
installationPendingPatch,
lastSyncedAt,
)
if (userId === null) {
await AsyncStorage.setItem(
INSTALLATION_CACHE_KEY,
serializeEnvelope(installationEnvelope),
)
return
}
const userEnvelope = createEnvelope(
preferences,
userPendingPatch,
lastSyncedAt,
)
await AsyncStorage.multiSet([
[INSTALLATION_CACHE_KEY, serializeEnvelope(installationEnvelope)],
[getUserCacheKey(userId), serializeEnvelope(userEnvelope)],
])
}
function toRowMutation(
userId: string,
preferences: MobilePreferences,
): Record<string, unknown> {
return {
user_id: userId,
theme_mode: preferences.themeMode,
locale: preferences.locale,
haptic_enabled: preferences.hapticEnabled,
auto_polish_enabled: preferences.autoPolishEnabled,
preferred_stt_model: preferences.preferredSttModel,
preferred_llm_model: preferences.preferredLlmModel,
onboarding_version: preferences.onboardingVersion,
tutorial_completed_at: preferences.tutorialCompletedAt,
revision: preferences.revision,
}
}
async function fetchServerPreferences(userId: string): Promise<MobilePreferences | null> {
const { data, error } = await supabase
.from('user_settings')
.select(USER_SETTINGS_COLUMNS)
.eq('user_id', userId)
.maybeSingle()
if (error) throw error
return parseSettingsRow(data)
}
async function writeServerPreferences(
userId: string,
fallback: MobilePreferences,
patch: MobilePreferencesPatch,
): Promise<MobilePreferences> {
for (let attempt = 0; attempt < 3; attempt += 1) {
const remote = await fetchServerPreferences(userId)
if (remote === null) {
const desired = applyPreferencesPatch(fallback, patch)
const insertValue: MobilePreferences = {
...desired,
revision: Math.max(1, desired.revision),
}
const { data, error } = await supabase
.from('user_settings')
.insert(toRowMutation(userId, insertValue))
.select(USER_SETTINGS_COLUMNS)
.single()
if (error?.code === '23505') continue
if (error) throw error
const inserted = parseSettingsRow(data)
if (inserted === null) throw new Error('Invalid user settings insert response')
return inserted
}
const desired: MobilePreferences = {
...applyPreferencesPatch(remote, patch),
revision: remote.revision + 1,
}
const { data, error } = await supabase
.from('user_settings')
.update(toRowMutation(userId, desired))
.eq('user_id', userId)
.eq('revision', remote.revision)
.select(USER_SETTINGS_COLUMNS)
.maybeSingle()
if (error) throw error
const updated = parseSettingsRow(data)
if (updated !== null) return updated
}
const conflict = new Error('User settings changed repeatedly on another device')
conflict.name = 'PreferencesSyncConflictError'
throw conflict
}
function isPatchEmpty(patch: MobilePreferencesPatch): boolean {
return Object.keys(patch).length === 0
}
function removeCommittedPatch(
current: MobilePreferencesPatch,
committed: MobilePreferencesPatch,
): MobilePreferencesPatch {
const remaining: MobilePreferencesPatch = { ...current }
for (const key of Object.keys(committed) as Array<keyof MobilePreferencesPatch>) {
if (remaining[key] === committed[key]) {
delete remaining[key]
}
}
return remaining
}
function errorCodeForSync(error: unknown): PreferencesErrorCode {
return error instanceof Error && error.name === 'PreferencesSyncConflictError'
? 'SYNC_CONFLICT'
: 'SYNC_FAILED'
}
export function MobilePreferencesProvider({
children,
store: injectedStore,
}: {
children: ReactNode
/** Test seam: defaults to the AsyncStorage + Supabase backed store. */
store?: PreferencesStore
}): React.ReactElement {
const { user } = useAuth()
const systemScheme = useColorScheme()
const userId = user?.id ?? null
const ownerKey = userId === null ? 'installation' : `user:${userId}`
const ownerKey = preferencesOwnerFor(userId).ownerKey
const [snapshot, setSnapshot] = useState<ProviderSnapshot>({
ownerKey: 'uninitialized',
preferences: { ...DEFAULT_PREFERENCES },
loading: true,
syncStatus: 'loading',
errorCode: null,
lastSyncedAt: null,
})
const ownerKeyRef = useRef(ownerKey)
const userIdRef = useRef<string | null>(userId)
const preferencesRef = useRef<MobilePreferences>({ ...DEFAULT_PREFERENCES })
const pendingPatchRef = useRef<MobilePreferencesPatch>({})
const lastSyncedAtRef = useRef<string | null>(null)
const loadGenerationRef = useRef(0)
const serverQueueRef = useRef<Promise<void>>(Promise.resolve())
const localQueueRef = useRef<Promise<void>>(Promise.resolve())
ownerKeyRef.current = ownerKey
userIdRef.current = userId
const flushPendingPatch = useCallback(async (targetUserId: string): Promise<boolean> => {
if (userIdRef.current !== targetUserId) return false
const committedPatch = { ...pendingPatchRef.current }
if (isPatchEmpty(committedPatch)) return true
setSnapshot((current) => current.ownerKey === `user:${targetUserId}`
? { ...current, syncStatus: 'saving', errorCode: null }
: current)
try {
const saved = await writeServerPreferences(
targetUserId,
preferencesRef.current,
committedPatch,
)
if (userIdRef.current !== targetUserId) return false
const remainingPatch = removeCommittedPatch(
pendingPatchRef.current,
committedPatch,
)
const nextPreferences = applyPreferencesPatch(saved, remainingPatch)
const syncedAt = new Date().toISOString()
preferencesRef.current = nextPreferences
pendingPatchRef.current = remainingPatch
lastSyncedAtRef.current = syncedAt
try {
await writeCaches(
targetUserId,
nextPreferences,
remainingPatch,
syncedAt,
)
setSnapshot({
ownerKey: `user:${targetUserId}`,
preferences: nextPreferences,
loading: false,
syncStatus: isPatchEmpty(remainingPatch) ? 'synced' : 'saving',
errorCode: null,
lastSyncedAt: syncedAt,
})
} catch {
setSnapshot({
ownerKey: `user:${targetUserId}`,
preferences: nextPreferences,
loading: false,
syncStatus: 'error',
errorCode: 'CACHE_WRITE_FAILED',
lastSyncedAt: syncedAt,
})
}
return isPatchEmpty(remainingPatch)
} catch (error) {
if (userIdRef.current !== targetUserId) return false
setSnapshot((current) => current.ownerKey === `user:${targetUserId}`
? {
...current,
loading: false,
syncStatus: 'offline',
errorCode: errorCodeForSync(error),
}
: current)
return false
}
}, [])
const queueServerFlush = useCallback((targetUserId: string): Promise<boolean> => {
const run = serverQueueRef.current.then(
() => flushPendingPatch(targetUserId),
() => flushPendingPatch(targetUserId),
)
serverQueueRef.current = run.then(() => undefined, () => undefined)
return run
}, [flushPendingPatch])
const loadPreferences = useCallback(async (
targetOwnerKey: string,
targetUserId: string | null,
): Promise<void> => {
const generation = ++loadGenerationRef.current
setSnapshot((current) => ({
...current,
ownerKey: targetOwnerKey,
loading: true,
syncStatus: 'loading',
errorCode: null,
}))
let installationEnvelope: CacheEnvelope | null = null
let userEnvelope: CacheEnvelope | null = null
let cacheReadFailed = false
let cacheWriteFailed = false
try {
const keys = targetUserId === null
? [INSTALLATION_CACHE_KEY]
: [INSTALLATION_CACHE_KEY, getUserCacheKey(targetUserId)]
const entries = await AsyncStorage.multiGet(keys)
installationEnvelope = parseCacheEnvelope(entries[0]?.[1] ?? null)
if (targetUserId !== null) {
userEnvelope = parseCacheEnvelope(entries[1]?.[1] ?? null)
}
} catch {
cacheReadFailed = true
}
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
return
}
if (targetUserId === null) {
const localPreferences = installationEnvelope?.preferences ?? { ...DEFAULT_PREFERENCES }
const localPending = installationEnvelope?.pendingPatch ?? {}
preferencesRef.current = localPreferences
pendingPatchRef.current = localPending
lastSyncedAtRef.current = installationEnvelope?.lastSyncedAt ?? null
setSnapshot({
ownerKey: targetOwnerKey,
preferences: localPreferences,
loading: false,
syncStatus: cacheReadFailed ? 'error' : 'local',
errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null,
lastSyncedAt: installationEnvelope?.lastSyncedAt ?? null,
})
return
}
const cachedPreferences = userEnvelope?.preferences
?? installationEnvelope?.preferences
?? { ...DEFAULT_PREFERENCES }
const stagedPatch: MobilePreferencesPatch = {
...(userEnvelope?.pendingPatch ?? {}),
...(installationEnvelope?.pendingPatch ?? {}),
}
const cachedWithPending = applyPreferencesPatch(cachedPreferences, stagedPatch)
preferencesRef.current = cachedWithPending
pendingPatchRef.current = stagedPatch
lastSyncedAtRef.current = userEnvelope?.lastSyncedAt
?? installationEnvelope?.lastSyncedAt
?? null
setSnapshot({
ownerKey: targetOwnerKey,
preferences: cachedWithPending,
loading: false,
syncStatus: 'loading',
errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null,
lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null,
})
try {
await writeCaches(
targetUserId,
cachedWithPending,
stagedPatch,
userEnvelope?.lastSyncedAt ?? null,
)
} catch {
cacheWriteFailed = true
}
let resolved: MobilePreferences
try {
const remote = await fetchServerPreferences(targetUserId)
if (remote === null || !isPatchEmpty(stagedPatch)) {
resolved = await writeServerPreferences(
targetUserId,
remote ?? cachedWithPending,
stagedPatch,
)
} else {
resolved = remote
}
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
return
}
} catch (error) {
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
return
}
setSnapshot({
ownerKey: targetOwnerKey,
preferences: cachedWithPending,
loading: false,
syncStatus: 'offline',
errorCode: cacheReadFailed
? 'CACHE_READ_FAILED'
: cacheWriteFailed
? 'CACHE_WRITE_FAILED'
: errorCodeForSync(error),
lastSyncedAt: userEnvelope?.lastSyncedAt ?? installationEnvelope?.lastSyncedAt ?? null,
})
return
}
if (generation !== loadGenerationRef.current || ownerKeyRef.current !== targetOwnerKey) {
return
}
const syncedAt = new Date().toISOString()
try {
await writeCaches(targetUserId, resolved, {}, syncedAt)
} catch {
cacheWriteFailed = true
}
preferencesRef.current = resolved
pendingPatchRef.current = {}
lastSyncedAtRef.current = syncedAt
setSnapshot({
ownerKey: targetOwnerKey,
preferences: resolved,
loading: false,
syncStatus: cacheReadFailed || cacheWriteFailed ? 'error' : 'synced',
errorCode: cacheReadFailed
? 'CACHE_READ_FAILED'
: cacheWriteFailed
? 'CACHE_WRITE_FAILED'
: null,
lastSyncedAt: syncedAt,
})
}, [])
const [store] = useState<PreferencesStore>(() => injectedStore ?? createDefaultStore())
const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot)
// Owner change → first load (the store alone decides initial vs background).
useEffect(() => {
void loadPreferences(ownerKey, userId)
}, [loadPreferences, ownerKey, userId])
void store.load(preferencesOwnerFor(userId))
}, [store, userId])
// Realtime rows from other devices.
useEffect(() => {
if (userId === null) return undefined
return subscribeToUserSettingsChanges(userId, (row) => {
store.applyRemote(userId, row)
})
}, [store, userId])
const channel = supabase
.channel(`mobile-user-settings-${userId}`)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'user_settings',
filter: `user_id=eq.${userId}`,
},
(payload) => {
if (userIdRef.current !== userId || !isPatchEmpty(pendingPatchRef.current)) return
const remote = parseSettingsRow(payload.new)
if (remote === null) return
// Foreground → background resync; never re-enters the initial-loading phase.
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state !== 'active' || !store.needsForegroundResync()) return
void store.resync()
})
return () => subscription.remove()
}, [store])
const syncedAt = new Date().toISOString()
preferencesRef.current = remote
lastSyncedAtRef.current = syncedAt
setSnapshot({
ownerKey: `user:${userId}`,
preferences: remote,
loading: false,
syncStatus: 'synced',
errorCode: null,
lastSyncedAt: syncedAt,
})
void writeCaches(userId, remote, {}, syncedAt).catch(() => {
if (userIdRef.current !== userId) return
setSnapshot((current) => ({
...current,
syncStatus: 'error',
errorCode: 'CACHE_WRITE_FAILED',
}))
})
},
)
.subscribe()
return () => {
void supabase.removeChannel(channel)
}
}, [userId])
const commitPatch = useCallback(async (
requestedPatch: MobilePreferencesPatch,
): Promise<PreferenceMutationResult> => {
const targetOwnerKey = ownerKeyRef.current
const targetUserId = userIdRef.current
const patch = normalizePreferencesPatch(requestedPatch)
if (isPatchEmpty(patch)) {
return {
localSaved: true,
serverSynced: targetUserId === null || isPatchEmpty(pendingPatchRef.current),
}
}
const nextPreferences = applyPreferencesPatch(preferencesRef.current, patch)
const nextPendingPatch = {
...pendingPatchRef.current,
...patch,
}
try {
await writeCaches(
targetUserId,
nextPreferences,
nextPendingPatch,
lastSyncedAtRef.current,
targetUserId === null ? nextPendingPatch : {},
)
} catch {
if (ownerKeyRef.current === targetOwnerKey) {
setSnapshot((current) => ({
...current,
syncStatus: 'error',
errorCode: 'CACHE_WRITE_FAILED',
}))
}
return { localSaved: false, serverSynced: false }
}
if (ownerKeyRef.current !== targetOwnerKey) {
return { localSaved: true, serverSynced: false }
}
preferencesRef.current = nextPreferences
pendingPatchRef.current = nextPendingPatch
setSnapshot((current) => ({
...current,
ownerKey: targetOwnerKey,
preferences: nextPreferences,
loading: false,
syncStatus: targetUserId === null ? 'local' : 'saving',
errorCode: null,
}))
if (targetUserId === null) {
return { localSaved: true, serverSynced: false }
}
const serverSynced = await queueServerFlush(targetUserId)
return { localSaved: true, serverSynced }
}, [queueServerFlush])
const updatePreferences = useCallback((
patch: MobilePreferencesPatch,
): Promise<PreferenceMutationResult> => {
const run = localQueueRef.current.then(
() => commitPatch(patch),
() => commitPatch(patch),
)
localQueueRef.current = run.then(() => undefined, () => undefined)
return run
}, [commitPatch])
const updatePreferences = useCallback(
(patch: MobilePreferencesPatch): Promise<PreferenceMutationResult> => store.commit(patch),
[store],
)
const completeOnboarding = useCallback((
outcome: 'completed' | 'skipped',
): Promise<PreferenceMutationResult> => updatePreferences({
): Promise<PreferenceMutationResult> => store.commit({
onboardingVersion: CURRENT_ONBOARDING_VERSION,
tutorialCompletedAt: outcome === 'completed' ? new Date().toISOString() : null,
}), [updatePreferences])
}), [store])
const retrySync = useCallback(async (): Promise<void> => {
const targetOwnerKey = ownerKeyRef.current
const targetUserId = userIdRef.current
if (targetUserId !== null && !isPatchEmpty(pendingPatchRef.current)) {
await queueServerFlush(targetUserId)
return
}
await loadPreferences(targetOwnerKey, targetUserId)
}, [loadPreferences, queueServerFlush])
useEffect(() => {
const subscription = AppState.addEventListener('change', (state) => {
if (state !== 'active' || userIdRef.current === null) return
if (isPatchEmpty(pendingPatchRef.current) && snapshot.syncStatus !== 'offline') return
void retrySync()
})
return () => subscription.remove()
}, [retrySync, snapshot.syncStatus])
const retrySync = useCallback((): Promise<void> => store.resync(), [store])
const effectiveTheme = resolveEffectiveTheme(
snapshot.preferences.themeMode,
@ -865,7 +141,7 @@ export function MobilePreferencesProvider({
() => getMobileThemePalette(effectiveTheme),
[effectiveTheme],
)
const loading = snapshot.loading || snapshot.ownerKey !== ownerKey
const loading = snapshot.phase === 'initial-loading' || snapshot.ownerKey !== ownerKey
const contextValue = useMemo<PreferencesContextValue>(() => ({
preferences: snapshot.preferences,

View file

@ -0,0 +1,898 @@
// Mobile preferences store — framework-free sync core.
//
// Owns the cache envelope codec, pending-patch bookkeeping, the revision-checked
// server writer and the owner/generation guards. IO is injected through the
// PreferencesStorage and PreferencesRemote ports so the whole sync flow can be
// unit-tested without React, AsyncStorage or Supabase. The React provider only
// subscribes to snapshots and forwards triggers (owner change, foreground,
// realtime rows, Settings retry).
import { isMobileThemeMode, type MobileThemeMode } from '../theme/mobile-theme'
export const CURRENT_ONBOARDING_VERSION = 1
const CACHE_SCHEMA_VERSION = 1
export const INSTALLATION_CACHE_KEY = '@d3ro/mobile/preferences/installation-v1'
export const USER_CACHE_PREFIX = '@d3ro/mobile/preferences/user-v1/'
export const USER_SETTINGS_COLUMNS = [
'user_id',
'theme_mode',
'locale',
'haptic_enabled',
'auto_polish_enabled',
'preferred_stt_model',
'preferred_llm_model',
'onboarding_version',
'tutorial_completed_at',
'revision',
'updated_at',
].join(',')
const MAX_WRITE_ATTEMPTS = 3
export type SupportedMobileLocale = 'ko' | 'en'
export type PreferencesSyncStatus =
| 'loading'
| 'local'
| 'saving'
| 'synced'
| 'offline'
| 'error'
export type PreferencesErrorCode =
| 'CACHE_READ_FAILED'
| 'CACHE_WRITE_FAILED'
| 'SYNC_FAILED'
| 'SYNC_CONFLICT'
export interface MobilePreferences {
themeMode: MobileThemeMode
locale: SupportedMobileLocale
hapticEnabled: boolean
autoPolishEnabled: boolean
preferredSttModel: string | null
preferredLlmModel: string | null
onboardingVersion: number
tutorialCompletedAt: string | null
revision: number
updatedAt: string | null
}
export type MobilePreferencesPatch = Partial<Pick<
MobilePreferences,
| 'themeMode'
| 'locale'
| 'hapticEnabled'
| 'autoPolishEnabled'
| 'preferredSttModel'
| 'preferredLlmModel'
| 'onboardingVersion'
| 'tutorialCompletedAt'
>>
export interface PreferenceMutationResult {
localSaved: boolean
serverSynced: boolean
}
/**
* 'initial-loading' is entered only when the store starts serving a new owner.
* Background resyncs of an already-loaded owner keep the phase at 'ready' so
* the app tree that depends on preferences never unmounts.
*/
export type PreferencesPhase = 'initial-loading' | 'ready'
export interface PreferencesOwner {
ownerKey: string
userId: string | null
}
export interface PreferencesStoreSnapshot {
readonly ownerKey: string
readonly phase: PreferencesPhase
readonly preferences: MobilePreferences
readonly syncStatus: PreferencesSyncStatus
readonly errorCode: PreferencesErrorCode | null
readonly lastSyncedAt: string | null
}
export type UserSettingsRowMutation = {
user_id: string
theme_mode: MobileThemeMode
locale: SupportedMobileLocale
haptic_enabled: boolean
auto_polish_enabled: boolean
preferred_stt_model: string | null
preferred_llm_model: string | null
onboarding_version: number
tutorial_completed_at: string | null
revision: number
}
/** Local key-value persistence port (AsyncStorage in production). */
export interface PreferencesStorage {
multiGet(keys: readonly string[]): Promise<ReadonlyArray<readonly [string, string | null]>>
setItem(key: string, value: string): Promise<void>
multiSet(pairs: Array<[string, string]>): Promise<void>
getAllKeys(): Promise<readonly string[]>
multiRemove(keys: readonly string[]): Promise<void>
}
export type RemoteInsertResult =
| { status: 'inserted', row: unknown }
| { status: 'duplicate' }
/** user_settings persistence port (Supabase in production). Every method throws on failure. */
export interface PreferencesRemote {
/** Returns the raw row, or null when the user has no settings row yet. */
fetchRow(userId: string): Promise<unknown>
/** Inserts a row; a unique-violation race is reported as 'duplicate'. */
insertRow(row: UserSettingsRowMutation): Promise<RemoteInsertResult>
/** Updates only when the stored revision still equals expectedRevision; null otherwise. */
updateRowAtRevision(row: UserSettingsRowMutation, expectedRevision: number): Promise<unknown>
}
export interface PreferencesStoreDeps {
storage: PreferencesStorage
remote: PreferencesRemote
now?: () => string
}
export interface PreferencesStore {
getSnapshot(): PreferencesStoreSnapshot
subscribe(listener: () => void): () => void
/** Switches to (or re-reads) an owner. Only an owner change enters 'initial-loading'. */
load(owner: PreferencesOwner): Promise<void>
/** Background resync of the current owner. Never re-enters 'initial-loading'. */
resync(): Promise<void>
/** Whether returning to the foreground should trigger resync(). */
needsForegroundResync(): boolean
/** Serialized local-first commit followed by a server flush for signed-in owners. */
commit(patch: MobilePreferencesPatch): Promise<PreferenceMutationResult>
/** Applies a realtime user_settings row unless local edits are still pending. */
applyRemote(userId: string, row: unknown): void
}
interface CacheEnvelope {
schemaVersion: number
preferences: MobilePreferences
pendingPatch: MobilePreferencesPatch
lastSyncedAt: string | null
}
interface SettingsRow {
user_id: string
theme_mode: unknown
locale: unknown
haptic_enabled: unknown
auto_polish_enabled: unknown
preferred_stt_model: unknown
preferred_llm_model: unknown
onboarding_version: unknown
tutorial_completed_at: unknown
revision: unknown
updated_at: unknown
}
export const DEFAULT_PREFERENCES: MobilePreferences = Object.freeze({
themeMode: 'system',
locale: 'ko',
hapticEnabled: true,
autoPolishEnabled: true,
preferredSttModel: null,
preferredLlmModel: null,
onboardingVersion: 0,
tutorialCompletedAt: null,
revision: 1,
updatedAt: null,
})
// ── Pure helpers ─────────────────────────────────────────────────────────────
export function preferencesOwnerFor(userId: string | null): PreferencesOwner {
return {
ownerKey: userId === null ? 'installation' : `user:${userId}`,
userId,
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
function normalizeLocale(value: unknown): SupportedMobileLocale {
return value === 'en' ? 'en' : 'ko'
}
function normalizeNullableString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0
? value.trim()
: null
}
function normalizeNonNegativeInteger(value: unknown, fallback: number): number {
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isSafeInteger(numeric) && numeric >= 0 ? numeric : fallback
}
function normalizePositiveInteger(value: unknown, fallback: number): number {
const numeric = typeof value === 'number' ? value : Number(value)
return Number.isSafeInteger(numeric) && numeric > 0 ? numeric : fallback
}
function normalizeIsoDate(value: unknown): string | null {
if (typeof value !== 'string') return null
const timestamp = Date.parse(value)
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null
}
export function normalizePreferences(
value: unknown,
fallback: MobilePreferences = DEFAULT_PREFERENCES,
): MobilePreferences {
if (!isRecord(value)) return { ...fallback }
return {
themeMode: isMobileThemeMode(value.themeMode) ? value.themeMode : fallback.themeMode,
locale: normalizeLocale(value.locale ?? fallback.locale),
hapticEnabled: typeof value.hapticEnabled === 'boolean'
? value.hapticEnabled
: fallback.hapticEnabled,
autoPolishEnabled: typeof value.autoPolishEnabled === 'boolean'
? value.autoPolishEnabled
: fallback.autoPolishEnabled,
preferredSttModel: value.preferredSttModel === undefined
? fallback.preferredSttModel
: normalizeNullableString(value.preferredSttModel),
preferredLlmModel: value.preferredLlmModel === undefined
? fallback.preferredLlmModel
: normalizeNullableString(value.preferredLlmModel),
onboardingVersion: normalizeNonNegativeInteger(
value.onboardingVersion,
fallback.onboardingVersion,
),
tutorialCompletedAt: value.tutorialCompletedAt === undefined
? fallback.tutorialCompletedAt
: normalizeIsoDate(value.tutorialCompletedAt),
revision: normalizePositiveInteger(value.revision, fallback.revision),
updatedAt: value.updatedAt === undefined
? fallback.updatedAt
: normalizeIsoDate(value.updatedAt),
}
}
export function normalizePreferencesPatch(value: unknown): MobilePreferencesPatch {
if (!isRecord(value)) return {}
const patch: MobilePreferencesPatch = {}
if (isMobileThemeMode(value.themeMode)) patch.themeMode = value.themeMode
if (value.locale === 'ko' || value.locale === 'en') patch.locale = value.locale
if (typeof value.hapticEnabled === 'boolean') patch.hapticEnabled = value.hapticEnabled
if (typeof value.autoPolishEnabled === 'boolean') {
patch.autoPolishEnabled = value.autoPolishEnabled
}
if (value.preferredSttModel === null || typeof value.preferredSttModel === 'string') {
patch.preferredSttModel = normalizeNullableString(value.preferredSttModel)
}
if (value.preferredLlmModel === null || typeof value.preferredLlmModel === 'string') {
patch.preferredLlmModel = normalizeNullableString(value.preferredLlmModel)
}
if (value.onboardingVersion !== undefined) {
patch.onboardingVersion = normalizeNonNegativeInteger(value.onboardingVersion, 0)
}
if (value.tutorialCompletedAt === null || typeof value.tutorialCompletedAt === 'string') {
patch.tutorialCompletedAt = normalizeIsoDate(value.tutorialCompletedAt)
}
return patch
}
export function applyPreferencesPatch(
preferences: MobilePreferences,
patch: MobilePreferencesPatch,
): MobilePreferences {
return normalizePreferences({ ...preferences, ...normalizePreferencesPatch(patch) }, preferences)
}
export function parseSettingsRow(value: unknown): MobilePreferences | null {
if (!isRecord(value) || typeof value.user_id !== 'string') return null
const row = value as unknown as SettingsRow
return normalizePreferences({
themeMode: row.theme_mode,
locale: row.locale,
hapticEnabled: row.haptic_enabled,
autoPolishEnabled: row.auto_polish_enabled,
preferredSttModel: row.preferred_stt_model,
preferredLlmModel: row.preferred_llm_model,
onboardingVersion: row.onboarding_version,
tutorialCompletedAt: row.tutorial_completed_at,
revision: row.revision,
updatedAt: row.updated_at,
})
}
function parseCacheEnvelope(raw: string | null): CacheEnvelope | null {
if (raw === null) return null
const parsed: unknown = JSON.parse(raw)
if (!isRecord(parsed) || parsed.schemaVersion !== CACHE_SCHEMA_VERSION) {
throw new Error('Unsupported mobile preferences cache schema')
}
return {
schemaVersion: CACHE_SCHEMA_VERSION,
preferences: normalizePreferences(parsed.preferences),
pendingPatch: normalizePreferencesPatch(parsed.pendingPatch),
lastSyncedAt: normalizeIsoDate(parsed.lastSyncedAt),
}
}
function serializeEnvelope(
preferences: MobilePreferences,
pendingPatch: MobilePreferencesPatch,
lastSyncedAt: string | null,
): string {
const envelope: CacheEnvelope = {
schemaVersion: CACHE_SCHEMA_VERSION,
preferences,
pendingPatch: normalizePreferencesPatch(pendingPatch),
lastSyncedAt,
}
return JSON.stringify(envelope)
}
function toInstallationPreferences(
preferences: MobilePreferences,
): MobilePreferences {
return {
...DEFAULT_PREFERENCES,
themeMode: preferences.themeMode,
locale: preferences.locale,
hapticEnabled: preferences.hapticEnabled,
onboardingVersion: preferences.onboardingVersion,
tutorialCompletedAt: preferences.tutorialCompletedAt,
}
}
export function getUserCacheKey(userId: string): string {
return `${USER_CACHE_PREFIX}${userId}`
}
export function toRowMutation(
userId: string,
preferences: MobilePreferences,
): UserSettingsRowMutation {
return {
user_id: userId,
theme_mode: preferences.themeMode,
locale: preferences.locale,
haptic_enabled: preferences.hapticEnabled,
auto_polish_enabled: preferences.autoPolishEnabled,
preferred_stt_model: preferences.preferredSttModel,
preferred_llm_model: preferences.preferredLlmModel,
onboarding_version: preferences.onboardingVersion,
tutorial_completed_at: preferences.tutorialCompletedAt,
revision: preferences.revision,
}
}
export function isPatchEmpty(patch: MobilePreferencesPatch): boolean {
return Object.keys(patch).length === 0
}
export function removeCommittedPatch(
current: MobilePreferencesPatch,
committed: MobilePreferencesPatch,
): MobilePreferencesPatch {
const remaining: MobilePreferencesPatch = { ...current }
for (const key of Object.keys(committed) as Array<keyof MobilePreferencesPatch>) {
if (remaining[key] === committed[key]) {
delete remaining[key]
}
}
return remaining
}
export function errorCodeForSync(error: unknown): PreferencesErrorCode {
return error instanceof Error && error.name === 'PreferencesSyncConflictError'
? 'SYNC_CONFLICT'
: 'SYNC_FAILED'
}
/** Cache failures outrank sync failures: CACHE_READ_FAILED > CACHE_WRITE_FAILED > sync. */
function resolveErrorCode(
cacheReadFailed: boolean,
cacheWriteFailed: boolean,
syncError: PreferencesErrorCode | null,
): PreferencesErrorCode | null {
if (cacheReadFailed) return 'CACHE_READ_FAILED'
if (cacheWriteFailed) return 'CACHE_WRITE_FAILED'
return syncError
}
// ── IO helpers over the ports ────────────────────────────────────────────────
export async function clearUserPreferenceCaches(storage: PreferencesStorage): Promise<void> {
const keys = await storage.getAllKeys()
const userKeys = keys.filter((key) => key.startsWith(USER_CACHE_PREFIX))
if (userKeys.length > 0) await storage.multiRemove(userKeys)
}
async function writeCaches(
storage: PreferencesStorage,
userId: string | null,
preferences: MobilePreferences,
userPendingPatch: MobilePreferencesPatch,
lastSyncedAt: string | null,
installationPendingPatch: MobilePreferencesPatch = {},
): Promise<void> {
const installationValue = serializeEnvelope(
toInstallationPreferences(preferences),
installationPendingPatch,
lastSyncedAt,
)
if (userId === null) {
await storage.setItem(INSTALLATION_CACHE_KEY, installationValue)
return
}
await storage.multiSet([
[INSTALLATION_CACHE_KEY, installationValue],
[getUserCacheKey(userId), serializeEnvelope(preferences, userPendingPatch, lastSyncedAt)],
])
}
async function fetchServerPreferences(
remote: PreferencesRemote,
userId: string,
): Promise<MobilePreferences | null> {
return parseSettingsRow(await remote.fetchRow(userId))
}
/**
* Revision-checked write: re-reads the row, applies the patch on top and
* updates only if nobody else bumped the revision in between. Retries a few
* times, then reports a PreferencesSyncConflictError.
*/
export async function writeServerPreferences(
remote: PreferencesRemote,
userId: string,
fallback: MobilePreferences,
patch: MobilePreferencesPatch,
): Promise<MobilePreferences> {
for (let attempt = 0; attempt < MAX_WRITE_ATTEMPTS; attempt += 1) {
const current = await fetchServerPreferences(remote, userId)
if (current === null) {
const desired = applyPreferencesPatch(fallback, patch)
const insertValue: MobilePreferences = {
...desired,
revision: Math.max(1, desired.revision),
}
const result = await remote.insertRow(toRowMutation(userId, insertValue))
if (result.status === 'duplicate') continue
const inserted = parseSettingsRow(result.row)
if (inserted === null) throw new Error('Invalid user settings insert response')
return inserted
}
const desired: MobilePreferences = {
...applyPreferencesPatch(current, patch),
revision: current.revision + 1,
}
const updated = parseSettingsRow(
await remote.updateRowAtRevision(toRowMutation(userId, desired), current.revision),
)
if (updated !== null) return updated
}
const conflict = new Error('User settings changed repeatedly on another device')
conflict.name = 'PreferencesSyncConflictError'
throw conflict
}
interface CacheReadResult {
installation: CacheEnvelope | null
user: CacheEnvelope | null
failed: boolean
}
async function readCaches(
storage: PreferencesStorage,
userId: string | null,
): Promise<CacheReadResult> {
try {
const keys = userId === null
? [INSTALLATION_CACHE_KEY]
: [INSTALLATION_CACHE_KEY, getUserCacheKey(userId)]
const entries = await storage.multiGet(keys)
return {
installation: parseCacheEnvelope(entries[0]?.[1] ?? null),
user: userId === null ? null : parseCacheEnvelope(entries[1]?.[1] ?? null),
failed: false,
}
} catch {
return { installation: null, user: null, failed: true }
}
}
// ── Store ────────────────────────────────────────────────────────────────────
type RefreshMode = 'initial' | 'background'
export function createPreferencesStore(deps: PreferencesStoreDeps): PreferencesStore {
const { storage, remote } = deps
const now = deps.now ?? (() => new Date().toISOString())
let owner: PreferencesOwner = { ownerKey: 'uninitialized', userId: null }
let snapshot: PreferencesStoreSnapshot = Object.freeze({
ownerKey: 'uninitialized',
phase: 'initial-loading',
preferences: { ...DEFAULT_PREFERENCES },
syncStatus: 'loading',
errorCode: null,
lastSyncedAt: null,
})
let preferences: MobilePreferences = { ...DEFAULT_PREFERENCES }
let pendingPatch: MobilePreferencesPatch = {}
let lastSyncedAt: string | null = null
let loadGeneration = 0
let serverQueue: Promise<void> = Promise.resolve()
let localQueue: Promise<void> = Promise.resolve()
let resyncInFlight: Promise<void> | null = null
const listeners = new Set<() => void>()
function setSnapshot(
next:
| PreferencesStoreSnapshot
| ((current: PreferencesStoreSnapshot) => PreferencesStoreSnapshot),
): void {
const resolved = typeof next === 'function' ? next(snapshot) : next
if (resolved === snapshot) return
snapshot = Object.freeze({ ...resolved })
for (const listener of [...listeners]) listener()
}
function isCurrentLoad(generation: number, ownerKey: string): boolean {
return generation === loadGeneration && owner.ownerKey === ownerKey
}
function enqueue<T>(
queue: 'server' | 'local',
task: () => Promise<T>,
): Promise<T> {
const tail = queue === 'server' ? serverQueue : localQueue
const run = tail.then(task, task)
const settled = run.then(() => undefined, () => undefined)
if (queue === 'server') serverQueue = settled
else localQueue = settled
return run
}
async function flushPendingPatch(targetUserId: string): Promise<boolean> {
if (owner.userId !== targetUserId) return false
const committedPatch = { ...pendingPatch }
if (isPatchEmpty(committedPatch)) return true
const targetOwnerKey = preferencesOwnerFor(targetUserId).ownerKey
setSnapshot((current) => current.ownerKey === targetOwnerKey
? { ...current, syncStatus: 'saving', errorCode: null }
: current)
try {
const saved = await writeServerPreferences(
remote,
targetUserId,
preferences,
committedPatch,
)
if (owner.userId !== targetUserId) return false
const remainingPatch = removeCommittedPatch(pendingPatch, committedPatch)
const nextPreferences = applyPreferencesPatch(saved, remainingPatch)
const syncedAt = now()
preferences = nextPreferences
pendingPatch = remainingPatch
lastSyncedAt = syncedAt
try {
await writeCaches(storage, targetUserId, nextPreferences, remainingPatch, syncedAt)
setSnapshot({
ownerKey: targetOwnerKey,
phase: 'ready',
preferences: nextPreferences,
syncStatus: isPatchEmpty(remainingPatch) ? 'synced' : 'saving',
errorCode: null,
lastSyncedAt: syncedAt,
})
} catch {
setSnapshot({
ownerKey: targetOwnerKey,
phase: 'ready',
preferences: nextPreferences,
syncStatus: 'error',
errorCode: 'CACHE_WRITE_FAILED',
lastSyncedAt: syncedAt,
})
}
return isPatchEmpty(remainingPatch)
} catch (error) {
if (owner.userId !== targetUserId) return false
setSnapshot((current) => current.ownerKey === targetOwnerKey
? {
...current,
phase: 'ready',
syncStatus: 'offline',
errorCode: errorCodeForSync(error),
}
: current)
return false
}
}
function queueServerFlush(targetUserId: string): Promise<boolean> {
return enqueue('server', () => flushPendingPatch(targetUserId))
}
async function refresh(target: PreferencesOwner, mode: RefreshMode): Promise<void> {
if (mode === 'background' && owner.ownerKey !== target.ownerKey) return
const targetOwnerKey = target.ownerKey
const targetUserId = target.userId
const generation = ++loadGeneration
setSnapshot((current) => {
if (mode === 'initial') {
return {
...current,
ownerKey: targetOwnerKey,
phase: 'initial-loading',
syncStatus: 'loading',
errorCode: null,
}
}
// Background: keep the phase so dependent UI stays mounted.
return current.ownerKey === targetOwnerKey
? { ...current, syncStatus: 'loading', errorCode: null }
: current
})
const cache = await readCaches(storage, targetUserId)
const cacheReadFailed = cache.failed
let cacheWriteFailed = false
if (!isCurrentLoad(generation, targetOwnerKey)) return
if (targetUserId === null) {
const localPreferences = cache.installation?.preferences ?? { ...DEFAULT_PREFERENCES }
preferences = localPreferences
pendingPatch = cache.installation?.pendingPatch ?? {}
lastSyncedAt = cache.installation?.lastSyncedAt ?? null
setSnapshot({
ownerKey: targetOwnerKey,
phase: 'ready',
preferences: localPreferences,
syncStatus: cacheReadFailed ? 'error' : 'local',
errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null,
lastSyncedAt,
})
return
}
const cachedPreferences = cache.user?.preferences
?? cache.installation?.preferences
?? { ...DEFAULT_PREFERENCES }
const stagedPatch: MobilePreferencesPatch = {
...(cache.user?.pendingPatch ?? {}),
...(cache.installation?.pendingPatch ?? {}),
}
const cachedWithPending = applyPreferencesPatch(cachedPreferences, stagedPatch)
const cachedSyncedAt = cache.user?.lastSyncedAt ?? cache.installation?.lastSyncedAt ?? null
preferences = cachedWithPending
pendingPatch = stagedPatch
lastSyncedAt = cachedSyncedAt
setSnapshot({
ownerKey: targetOwnerKey,
phase: 'ready',
preferences: cachedWithPending,
syncStatus: 'loading',
errorCode: cacheReadFailed ? 'CACHE_READ_FAILED' : null,
lastSyncedAt: cachedSyncedAt,
})
try {
await writeCaches(
storage,
targetUserId,
cachedWithPending,
stagedPatch,
cache.user?.lastSyncedAt ?? null,
)
} catch {
cacheWriteFailed = true
}
let resolved: MobilePreferences
try {
const server = await fetchServerPreferences(remote, targetUserId)
resolved = server === null || !isPatchEmpty(stagedPatch)
? await writeServerPreferences(
remote,
targetUserId,
server ?? cachedWithPending,
stagedPatch,
)
: server
} catch (error) {
if (!isCurrentLoad(generation, targetOwnerKey)) return
setSnapshot({
ownerKey: targetOwnerKey,
phase: 'ready',
preferences: cachedWithPending,
syncStatus: 'offline',
errorCode: resolveErrorCode(cacheReadFailed, cacheWriteFailed, errorCodeForSync(error)),
lastSyncedAt: cachedSyncedAt,
})
return
}
if (!isCurrentLoad(generation, targetOwnerKey)) return
// Edits committed while this refresh was in flight stay pending on top of
// the server value instead of being dropped.
const remainingPatch = removeCommittedPatch(pendingPatch, stagedPatch)
const nextPreferences = applyPreferencesPatch(resolved, remainingPatch)
const syncedAt = now()
preferences = nextPreferences
pendingPatch = remainingPatch
lastSyncedAt = syncedAt
try {
await writeCaches(storage, targetUserId, nextPreferences, remainingPatch, syncedAt)
} catch {
cacheWriteFailed = true
}
if (!isCurrentLoad(generation, targetOwnerKey)) return
// Read the in-memory state again: a commit may have landed during the cache write.
const errorCode = resolveErrorCode(cacheReadFailed, cacheWriteFailed, null)
setSnapshot({
ownerKey: targetOwnerKey,
phase: 'ready',
preferences,
syncStatus: errorCode !== null
? 'error'
: isPatchEmpty(pendingPatch) ? 'synced' : 'saving',
errorCode,
lastSyncedAt: syncedAt,
})
}
function load(target: PreferencesOwner): Promise<void> {
const sameOwnerReady = snapshot.ownerKey === target.ownerKey && snapshot.phase === 'ready'
owner = { ...target }
return refresh(owner, sameOwnerReady ? 'background' : 'initial')
}
function resync(): Promise<void> {
const target = owner
if (target.userId !== null && !isPatchEmpty(pendingPatch)) {
return queueServerFlush(target.userId).then(() => undefined)
}
if (resyncInFlight !== null) return resyncInFlight
const run = enqueue('server', () => refresh(target, 'background'))
const tracked = run.finally(() => {
if (resyncInFlight === tracked) resyncInFlight = null
})
resyncInFlight = tracked
return tracked
}
function needsForegroundResync(): boolean {
if (owner.userId === null) return false
return !isPatchEmpty(pendingPatch) || snapshot.syncStatus === 'offline'
}
async function commitNow(
requestedPatch: MobilePreferencesPatch,
): Promise<PreferenceMutationResult> {
const targetOwnerKey = owner.ownerKey
const targetUserId = owner.userId
const patch = normalizePreferencesPatch(requestedPatch)
if (isPatchEmpty(patch)) {
return {
localSaved: true,
serverSynced: targetUserId === null || isPatchEmpty(pendingPatch),
}
}
const nextPreferences = applyPreferencesPatch(preferences, patch)
const nextPendingPatch = { ...pendingPatch, ...patch }
try {
await writeCaches(
storage,
targetUserId,
nextPreferences,
nextPendingPatch,
lastSyncedAt,
targetUserId === null ? nextPendingPatch : {},
)
} catch {
if (owner.ownerKey === targetOwnerKey) {
setSnapshot((current) => ({
...current,
syncStatus: 'error',
errorCode: 'CACHE_WRITE_FAILED',
}))
}
return { localSaved: false, serverSynced: false }
}
if (owner.ownerKey !== targetOwnerKey) {
return { localSaved: true, serverSynced: false }
}
preferences = nextPreferences
pendingPatch = nextPendingPatch
setSnapshot((current) => ({
...current,
ownerKey: targetOwnerKey,
phase: 'ready',
preferences: nextPreferences,
syncStatus: targetUserId === null ? 'local' : 'saving',
errorCode: null,
}))
if (targetUserId === null) {
return { localSaved: true, serverSynced: false }
}
const serverSynced = await queueServerFlush(targetUserId)
return { localSaved: true, serverSynced }
}
function commit(patch: MobilePreferencesPatch): Promise<PreferenceMutationResult> {
return enqueue('local', () => commitNow(patch))
}
function applyRemote(userId: string, row: unknown): void {
if (owner.userId !== userId || !isPatchEmpty(pendingPatch)) return
const parsed = parseSettingsRow(row)
if (parsed === null) return
const syncedAt = now()
preferences = parsed
lastSyncedAt = syncedAt
setSnapshot({
ownerKey: preferencesOwnerFor(userId).ownerKey,
phase: 'ready',
preferences: parsed,
syncStatus: 'synced',
errorCode: null,
lastSyncedAt: syncedAt,
})
void writeCaches(storage, userId, parsed, {}, syncedAt).catch(() => {
if (owner.userId !== userId) return
setSnapshot((current) => ({
...current,
syncStatus: 'error',
errorCode: 'CACHE_WRITE_FAILED',
}))
})
}
return {
getSnapshot: () => snapshot,
subscribe(listener) {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
},
load,
resync,
needsForegroundResync,
commit,
applyRemote,
}
}

View file

@ -0,0 +1,57 @@
import { RecorderBusyError } from './recorder/recorder-errors'
import type {
RecorderOwner,
RecorderSession,
RecordingRuntimeSnapshot,
} from './recorder/recorder-types'
/** The part of the shared recorder facade this policy needs. */
export interface CaptureRecorderPort {
acquire(owner: RecorderOwner): Promise<RecorderSession>
}
function isLive(snapshot: RecordingRuntimeSnapshot): boolean {
return snapshot.state === 'recording' || snapshot.state === 'paused'
}
function isReattachable(snapshot: RecordingRuntimeSnapshot): boolean {
return (snapshot.state === 'stopped' || snapshot.state === 'recoverable')
&& snapshot.recording !== null
}
/**
* Stops the microphone when the signed-in session is lost involuntarily, but
* keeps the meeting capture's file so its owner can process it after signing
* back in (Record restores a stopped capture as a recoverable recording).
*
* - A Talk turn is transient conversation input, not durable work: discarded.
* - A capture the backend cannot hand back after the screen unmounts (the
* in-process iOS recorder reports no reattachable snapshot) is discarded as
* before, because a stranded stopped capture would block every new
* recording.
*/
export async function stopLiveCaptureKeepingFile(recorder: CaptureRecorderPort): Promise<void> {
let session: RecorderSession
try {
session = await recorder.acquire('record')
} catch (error) {
if (!(error instanceof RecorderBusyError)) throw error
const holder = await recorder.acquire(error.holder)
await holder.cancel()
return
}
try {
await session.stop()
} catch {
// Nothing is driven by this JS process (idle, already stopped, or a
// foreground-service capture that outlived a restart): inspect it below.
}
let snapshot = await session.restore()
if (isLive(snapshot)) {
await session.stop()
snapshot = await session.restore()
}
if (isReattachable(snapshot)) return
await session.cancel()
}

View file

@ -0,0 +1,77 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
const STORAGE_KEY = '@d3ro/account-retained-work/v1'
const MAX_USER_ID_LENGTH = 128
/** Storage port; AsyncStorage in the app, an in-memory fake in tests. */
export interface RetainedWorkStoragePort {
getItem(key: string): Promise<string | null>
setItem(key: string, value: string): Promise<void>
removeItem(key: string): Promise<void>
}
/**
* Persisted marker naming the account whose unsynced recordings were kept on
* this device when its session was lost involuntarily. It survives a restart
* so the next boundary can tell "the owner came back" (keep and resume) from
* "another account arrived" (discard before that account sees the device).
*
* `current()` is synchronous so the auth boundary can decide without an extra
* await; `load()` must have settled once before the first decision.
*/
export interface RetainedWorkOwnerStore {
load(): Promise<string | null>
current(): string | null
retain(userId: string): Promise<void>
release(): Promise<void>
}
function parseOwner(raw: string | null): string | null {
if (raw === null) return null
const trimmed = raw.trim()
return trimmed.length > 0 && trimmed.length <= MAX_USER_ID_LENGTH ? trimmed : null
}
export function createRetainedWorkOwnerStore(
storage: RetainedWorkStoragePort,
): RetainedWorkOwnerStore {
let cached: string | null = null
let version = 0
let loading: Promise<string | null> | null = null
return {
load(): Promise<string | null> {
if (loading !== null) return loading
const loadVersion = version
const operation = storage.getItem(STORAGE_KEY).then((raw) => {
// A retain/release issued while the read was in flight is newer.
if (version === loadVersion) cached = parseOwner(raw)
return cached
})
loading = operation
operation.catch(() => {
if (loading === operation) loading = null
})
return operation
},
current(): string | null {
return cached
},
async retain(userId: string): Promise<void> {
const owner = parseOwner(userId)
if (owner === null) throw new Error('retained_work_owner_invalid')
version += 1
cached = owner
await storage.setItem(STORAGE_KEY, owner)
},
async release(): Promise<void> {
version += 1
cached = null
await storage.removeItem(STORAGE_KEY)
},
}
}
export const retainedAccountWork: RetainedWorkOwnerStore = createRetainedWorkOwnerStore(AsyncStorage)
export const retainedAccountWorkTestContract = { storageKey: STORAGE_KEY }

View file

@ -41,6 +41,21 @@ import {
import { useMobilePreferences } from '../lib/preferences-context'
import { useDevice } from '../lib/device-context'
import { detachPushRegistrationForLogout } from '../features/notifications/notification-service'
import {
accountDeletionFailureMessageKey,
edgeAccountDeletionService,
runAccountDeletion,
type AccountDeletionService,
} from '../features/account/account-deletion-service'
// Public surface kept for existing importers; the use case now lives in
// features/account so it is testable without rendering this screen.
export {
edgeAccountDeletionService,
type AccountDeletionFailureCode,
type AccountDeletionResult,
type AccountDeletionService,
} from '../features/account/account-deletion-service'
const PROFILE_COLUMNS = 'id, name, avatar_url, locale, tier, created_at, updated_at'
const HTTP_URL_PATTERN = /^https?:\/\//i
@ -57,42 +72,6 @@ type Profile = {
updated_at: string
}
export type AccountDeletionFailureCode =
| 'SERVER_ENDPOINT_UNAVAILABLE'
| 'REAUTHENTICATION_REQUIRED'
| 'REQUEST_FAILED'
export type AccountDeletionResult =
| { ok: true }
| { ok: false; code: AccountDeletionFailureCode; message?: string }
/** Server-owned boundary for deleting auth.users and its cascaded account data. */
export interface AccountDeletionService {
deleteCurrentAccount(): Promise<AccountDeletionResult>
}
export const edgeAccountDeletionService: AccountDeletionService = Object.freeze({
async deleteCurrentAccount(): Promise<AccountDeletionResult> {
const { data, error } = await supabase.functions.invoke('account-delete', {
body: { confirmation: 'DELETE_MY_ACCOUNT' },
})
const response = data as { success?: boolean; code?: string; error?: string } | null
if (error || response?.success !== true) {
if (response?.code === 'REAUTHENTICATION_REQUIRED') {
return { ok: false, code: 'REAUTHENTICATION_REQUIRED', message: response.error }
}
return {
ok: false,
code: 'REQUEST_FAILED',
message: response?.error ?? error?.message,
}
}
return { ok: true }
},
})
export interface AccountScreenProps {
deletionService?: AccountDeletionService
identityService?: IdentityManagementService
@ -413,26 +392,25 @@ export default function AccountScreen({
setErrorMessage(null)
setSuccessMessage(null)
const userId = user?.id ?? null
const deviceId = currentDevice?.id ?? null
try {
if (user?.id && currentDevice?.id) {
await detachPushRegistrationForLogout(user.id, currentDevice.id).catch(() => undefined)
}
const result = await deletionService.deleteCurrentAccount()
// Push is detached only after the server confirms auth.users deletion,
// then the central privacy boundary removes every account-owned local
// artifact and the secure Supabase session before Login can render.
const result = await runAccountDeletion({
deletionService,
detachPushRegistration: async () => {
if (userId && deviceId) await detachPushRegistrationForLogout(userId, deviceId)
},
purgeLocalSession,
})
if (!result.ok) {
const message = result.code === 'SERVER_ENDPOINT_UNAVAILABLE'
? t('mobile.account.deleteUnavailable')
: result.code === 'REAUTHENTICATION_REQUIRED'
? t('mobile.account.reauthenticationRequired')
: result.message ?? t('mobile.account.deleteFailed')
const message = t(accountDeletionFailureMessageKey(result.code))
setErrorMessage(message)
Alert.alert(t('mobile.auth.errorTitle'), message)
return
}
// Only reachable after the server confirms auth.users deletion. The
// central privacy boundary removes every account-owned local artifact
// and the secure Supabase session before Login can render.
await purgeLocalSession()
} catch {
const message = t('mobile.auth.localDataCleanupBody')
setErrorMessage(message)