Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions __tests__/useBrowserActionApproval.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
jest.mock('expo-notifications', () => ({
setNotificationCategoryAsync: jest.fn().mockResolvedValue(true),
registerTaskAsync: jest.fn().mockResolvedValue(true),
}))

jest.mock('expo-task-manager', () => ({
defineTask: jest.fn(),
isTaskDefined: jest.fn().mockReturnValue(false),
}))

jest.mock('@react-native-firebase/auth', () => ({
getAuth: jest.fn().mockReturnValue({ currentUser: { getIdToken: jest.fn().mockResolvedValue('id-tok') } }),
}))

jest.mock('../shared/localCloudAgent', () => ({
getCloudAgentBaseUrl: () => 'https://agent.test',
}))

import * as TaskManager from 'expo-task-manager'
import { setupBrowserActionApproval } from '~/hooks/useBrowserActionApproval'

describe('setupBrowserActionApproval', () => {
it('registers BROWSER_ACTION_APPROVAL notification category', async () => {
const Notifications = require('expo-notifications') as { setNotificationCategoryAsync: jest.Mock }
await setupBrowserActionApproval()
expect(Notifications.setNotificationCategoryAsync).toHaveBeenCalledWith(
'BROWSER_ACTION_APPROVAL',
expect.arrayContaining([
expect.objectContaining({ identifier: 'APPROVE' }),
expect.objectContaining({ identifier: 'DENY' }),
]),
)
expect(TaskManager.defineTask).toHaveBeenCalledWith(
'BROWSER_ACTION_APPROVAL_RESPONSE',
expect.any(Function),
)
})
})
33 changes: 33 additions & 0 deletions __tests__/useRegisterExpoPushToken.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { renderHook, waitFor } from '@testing-library/react-native'
import { useRegisterExpoPushToken } from '~/hooks/useRegisterExpoPushToken'

jest.mock('expo-notifications', () => ({
getPermissionsAsync: jest.fn().mockResolvedValue({ status: 'undetermined' }),
requestPermissionsAsync: jest.fn().mockResolvedValue({ status: 'granted' }),
getExpoPushTokenAsync: jest.fn().mockResolvedValue({ data: 'ExponentPushToken[test]' }),
setNotificationCategoryAsync: jest.fn().mockResolvedValue(true),
}))

jest.mock('~/config/firebaseConfig', () => ({
getCurrentUser: jest.fn().mockReturnValue({ getIdToken: jest.fn().mockResolvedValue('id-tok') }),
}))

jest.mock('../shared/localCloudAgent', () => ({
getCloudAgentBaseUrl: () => 'https://agent.test',
}))

describe('useRegisterExpoPushToken', () => {
beforeEach(() => {
global.fetch = jest.fn().mockResolvedValue({ ok: true, json: async () => ({ ok: true }) }) as jest.Mock
})

it('registers token and POSTs to cloud agent', async () => {
renderHook(() => useRegisterExpoPushToken({ enabled: true, projectId: 'test-proj' }))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
expect.stringContaining('/agent/user/expo-push-token'),
expect.objectContaining({ method: 'POST' }),
)
})
})
})
10 changes: 10 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ import {
useCharacterMachine,
useTermsMachine,
} from '~/hooks/useMachines'
import { useRegisterExpoPushToken } from '~/hooks/useRegisterExpoPushToken'
import { useBrowserActionApproval } from '~/hooks/useBrowserActionApproval'
import Constants from 'expo-constants'

/**
* Wires cross-machine coordination in one place.
Expand Down Expand Up @@ -133,6 +136,13 @@ function AppOrchestrator({ children }: { children: React.ReactNode }) {
return () => subscription.remove()
}, [authService])

const isSignedIn = useSelector(authService, (state) => state.matches('signedIn'))
useRegisterExpoPushToken({
enabled: isSignedIn,
projectId: Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId ?? '',
})
Comment thread
equationalapplications marked this conversation as resolved.
useBrowserActionApproval()

return <>{children}</>
}

Expand Down
1 change: 1 addition & 0 deletions cloud-agent/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const users = pgTable('users', {
firebaseUid: text('firebase_uid').unique().notNull(),
email: text('email').unique().notNull(),
displayName: text('display_name'),
expoPushToken: text('expo_push_token'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
})
Expand Down
36 changes: 36 additions & 0 deletions cloud-agent/src/handlers/approveAction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { handleApproveAction } from './approveAction.js'

test('handleApproveAction writes approved to auth doc', async () => {
const updates: Array<{ path: string; data: Record<string, unknown> }> = []
const fakeDb = {
doc: (path: string) => ({
update: async (data: Record<string, unknown>) => { updates.push({ path, data }) },
}),
}

await handleApproveAction(fakeDb as never, 'uid1', {
sessionId: 'sid1', taskId: 'tid1', approve: true, idToken: 'raw-token',
})

assert.equal(updates.length, 1)
assert.equal(updates[0].path, 'users/uid1/sessions/sid1/auth/tid1')
assert.equal(updates[0].data.status, 'approved')
assert.equal(updates[0].data.approvalToken, 'raw-token')
})

test('handleApproveAction writes denied to auth doc', async () => {
const updates: Array<{ path: string; data: Record<string, unknown> }> = []
const fakeDb = {
doc: (path: string) => ({
update: async (data: Record<string, unknown>) => { updates.push({ path, data }) },
}),
}

await handleApproveAction(fakeDb as never, 'uid1', {
sessionId: 'sid1', taskId: 'tid1', approve: false, idToken: '',
})

assert.equal(updates[0].data.status, 'denied')
})
29 changes: 29 additions & 0 deletions cloud-agent/src/handlers/approveAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import admin from 'firebase-admin'

interface ApproveActionBody {
sessionId: string
taskId: string
approve: boolean
idToken: string
}

interface FirestoreLite {
doc(path: string): { update(data: Record<string, unknown>): Promise<void> }
}

export async function handleApproveAction(
db: FirestoreLite,
uid: string,
body: ApproveActionBody,
): Promise<void> {
const authPath = `users/${uid}/sessions/${body.sessionId}/auth/${body.taskId}`
if (body.approve) {
await db.doc(authPath).update({
status: 'approved',
approvalToken: body.idToken,
approvedAt: admin.firestore?.Timestamp?.now?.() ?? new Date(),
})
} else {
await db.doc(authPath).update({ status: 'denied' })
}
}
119 changes: 119 additions & 0 deletions cloud-agent/src/handlers/authApprovalObserver.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { startAuthApprovalObserver } from './authApprovalObserver.js'

function baseDeps(over: Record<string, unknown> = {}) {
const results: unknown[] = []
const pushes: unknown[] = []
let watcher: ((auth: Record<string, unknown>) => void) | null = null
let unsubbed = false

const deps = {
fs: {
watchAuth: (_u: string, _s: string, _t: string, cb: (a: Record<string, unknown>) => void) => {
watcher = cb
return () => { unsubbed = true }
},
writeTaskResult: async (...a: unknown[]) => { results.push(a) },
},
fcmDispatcher: {
wakeExtension: async (...a: unknown[]) => { pushes.push({ type: 'wake', args: a }) },
sendTaskComplete: async (...a: unknown[]) => { pushes.push({ type: 'complete', args: a }) },
},
verifyToken: async () => ({ uid: 'uid1' }),
getExpoPushToken: async () => 'ExponentPushToken[x]',
firebaseUid: 'uid1',
sessionId: 'sid1',
taskId: 'tid1',
intent: {
version: '1', taskId: 'tid1', sessionId: 'sid1', requiresAuth: true,
actionSummary: 'Submit', action: { type: 'click', selector: '#s', tier: 'stateful' },
},
deviceFcmToken: 'gcm-tok',
authApprovalTtlMs: 50,
...over,
}

return { deps, getWatcher: () => watcher, results, pushes, getUnsubbed: () => unsubbed }
}

test('denied writes aborted + sendTaskComplete and unsubscribes', async () => {
const { deps, getWatcher, results, pushes, getUnsubbed } = baseDeps()
startAuthApprovalObserver(deps as never)

getWatcher()!({ status: 'denied', approvalToken: null, approvedAt: null, expiresAt: 0, actionSummary: '' })
await new Promise((r) => setTimeout(r, 10))

assert.equal(results.length, 1)
const result = (results[0] as unknown[])[3] as { status: string; error: { code: string; message: string } }
assert.equal(result.status, 'aborted')
assert.equal(result.error.code, 'AUTH_TIMEOUT')
assert.match(result.error.message, /denied/i)
assert.equal(pushes.length, 1)
assert.equal((pushes[0] as { type: string }).type, 'complete')
assert.ok(getUnsubbed())
})

test('approved verifies token and sends FCM resume wake', async () => {
const { deps, getWatcher, pushes } = baseDeps({
verifyToken: async (t: string) => {
assert.equal(t, 'approval-tok')
return { uid: 'uid1' }
},
})
startAuthApprovalObserver(deps as never)

getWatcher()!({ status: 'approved', approvalToken: 'approval-tok', approvedAt: null, expiresAt: 0, actionSummary: '' })
await new Promise((r) => setTimeout(r, 10))

assert.equal(pushes.length, 1)
assert.equal((pushes[0] as { type: string }).type, 'wake')
const wakeArgs = (pushes[0] as { args: unknown[] }).args
assert.equal(wakeArgs[3], true)
})

test('failed FCM wake aborts task instead of resolving', async () => {
const { deps, getWatcher, results } = baseDeps({
fcmDispatcher: {
wakeExtension: async () => { throw new Error('FCM error') },
sendTaskComplete: async () => {},
},
})
startAuthApprovalObserver(deps as never)

getWatcher()!({ status: 'approved', approvalToken: 'approval-tok', approvedAt: null, expiresAt: 0, actionSummary: '' })
await new Promise((r) => setTimeout(r, 20))

assert.ok(results.length >= 1)
const result = (results[0] as unknown[])[3] as { status: string; error: { message: string } }
assert.equal(result.status, 'aborted')
assert.match(result.error.message, /wake/i)
})

test('5-minute TTL aborts with AUTH_TIMEOUT and sendTaskComplete', async () => {
const { deps, results, pushes } = baseDeps({ authApprovalTtlMs: 30 })
startAuthApprovalObserver(deps as never)

await new Promise((r) => setTimeout(r, 50))

assert.equal(results.length, 1)
const result = (results[0] as unknown[])[3] as { error: { code: string; message: string } }
assert.equal(result.error.code, 'AUTH_TIMEOUT')
assert.match(result.error.message, /timed out/i)
assert.equal(pushes.length, 1)
})

test('invalid approval token aborts and notifies mobile', async () => {
const { deps, getWatcher, results, pushes } = baseDeps({
verifyToken: async () => { throw new Error('bad token') },
})
startAuthApprovalObserver(deps as never)

getWatcher()!({ status: 'approved', approvalToken: 'bad', approvedAt: null, expiresAt: 0, actionSummary: '' })
await new Promise((r) => setTimeout(r, 10))

assert.equal(results.length, 1)
const result = (results[0] as unknown[])[3] as { error: { message: string } }
assert.match(result.error.message, /invalid/i)
assert.equal(pushes.length, 1)
})
96 changes: 96 additions & 0 deletions cloud-agent/src/handlers/authApprovalObserver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { FirestoreSession } from '../services/firestoreSession.js'
import type { FcmDispatcher } from '../services/fcmDispatcher.js'
import type { TaskIntent } from '../../../shared/dsl-types.js'

export const AUTH_APPROVAL_TTL_MS = 5 * 60 * 1000

export interface AuthApprovalObserverDeps {
fs: FirestoreSession
fcmDispatcher?: FcmDispatcher
verifyToken: (token: string) => Promise<{ uid: string }>
getExpoPushToken?: (uid: string) => Promise<string | null>
firebaseUid: string
sessionId: string
taskId: string
intent: TaskIntent
deviceFcmToken: string | null
authApprovalTtlMs?: number
/** Best-effort callback after approval resolves (e.g. session_end if WS still open). */
onResolved?: () => void
}

async function notifyMobile(
deps: AuthApprovalObserverDeps,
summary: string,
): Promise<void> {
if (!deps.fcmDispatcher || !deps.getExpoPushToken) return
const expoPushToken = await deps.getExpoPushToken(deps.firebaseUid)
if (!expoPushToken) return
await deps.fcmDispatcher.sendTaskComplete(expoPushToken, deps.sessionId, deps.taskId, summary).catch(
(err) => console.error('sendTaskComplete failed:', err),
)
}

/** Self-cleaning auth-doc observer — lifetime is independent of the browser WebSocket. */
export function startAuthApprovalObserver(deps: AuthApprovalObserverDeps): void {
let settled = false
let unsub: (() => void) | null = null
const ttlMs = deps.authApprovalTtlMs ?? AUTH_APPROVAL_TTL_MS

const cleanup = (): void => {
clearTimeout(ttlTimer)
unsub?.()
unsub = null
}

const settle = (fn: () => Promise<void>): void => {
if (settled) return
settled = true
cleanup()
void fn().catch((err) => console.error('auth approval observer error:', err))
Comment thread
equationalapplications marked this conversation as resolved.
}

const abortTask = async (message: string): Promise<void> => {
await deps.fs.writeTaskResult(deps.firebaseUid, deps.sessionId, deps.taskId, {
taskId: deps.taskId, status: 'aborted', data: {}, activeUrl: '',
error: { code: 'AUTH_TIMEOUT', message, failedAction: deps.intent.action as never },
})
await notifyMobile(deps, message)
deps.onResolved?.()
}

const ttlTimer = setTimeout(() => {
settle(() => abortTask('Approval timed out. The action was not completed.'))
}, ttlMs)
ttlTimer.unref?.()

unsub = deps.fs.watchAuth(deps.firebaseUid, deps.sessionId, deps.taskId, (auth) => {
if (auth.status === 'pending') return

if (auth.status === 'approved') {
settle(async () => {
try {
const decoded = await deps.verifyToken(auth.approvalToken ?? '')
if (decoded.uid !== deps.firebaseUid) throw new Error('UID mismatch')
} catch {
await abortTask('Approval token invalid. The action was not completed.')
return
}

if (deps.fcmDispatcher && deps.deviceFcmToken) {
try {
await deps.fcmDispatcher.wakeExtension(deps.deviceFcmToken, deps.sessionId, deps.taskId, true)
} catch (err) {
console.error('FCM resume wake failed:', err)
await abortTask('Failed to wake the extension after approval. The action was not completed.')
return
}
}
deps.onResolved?.()
})
return
}

settle(() => abortTask('Action was denied.'))
})
}
Loading