From 31cff32824ab750793be5c83f2321b53506b2bb0 Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 18:37:17 +0100 Subject: [PATCH] [feat](client): join without an account; the token the join returns is the identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There is no anonymous session to start and nothing to mint: the app comes up on Home with no account, and the first QR scan performs the unauthenticated joinAlert, which returns a deviceToken the client stores with the alert. That token — not a user, not a session — is what the subscriber calls carry as X-Device-Token, and what the socket puts in ?deviceToken=. - apiClient takes an optional device token per request; when present it is sent instead of the access token, because it is the narrower credential - alarmApi.joinAlert returns it; getAlert/alarmStatus/ackAlarm/ removeSubscription/qrImage and the socket URL accept it - the alert record keeps the token beside its subscribe token, so a queued ack for an alert that is gone still resolves the right device - login is now a destination, never a wall: without a session the app is the subscriber view, and the login screen's 'continue without an account' button is gone because there is nothing to continue from tsc clean, 129 tests (8 new ones pin the header transport and the no-access-token join). --- App.tsx | 10 +-- __tests__/deviceToken.test.ts | 137 ++++++++++++++++++++++++++++++ src/screens/AlertDetailScreen.tsx | 15 +++- src/screens/HomeScreen.tsx | 63 +++++++++++--- src/screens/SettingsScreen.tsx | 10 ++- src/services/alarmApi.ts | 62 ++++++++++---- src/services/apiClient.ts | 34 ++++++-- src/services/instanceUrl.ts | 5 +- src/services/pendingAcks.ts | 6 +- src/state/AlarmContext.tsx | 20 ++++- src/state/AuthContext.tsx | 14 +++ src/state/useAlarmSocket.ts | 14 ++- 12 files changed, 326 insertions(+), 64 deletions(-) create mode 100644 __tests__/deviceToken.test.ts diff --git a/App.tsx b/App.tsx index 456919c..4901d43 100644 --- a/App.tsx +++ b/App.tsx @@ -132,11 +132,9 @@ function Root() { useEffect(() => { if (token === prevToken.current) return; prevToken.current = token; - if (token) { - navigation.reset({ index: 0, routes: [{ name: 'Home' }] }); - } else { - navigation.reset({ index: 0, routes: [{ name: 'Login' }] }); - } + // Home is always reachable: without an account it is the subscriber view + // (rev 10). Login is an explicit destination, never a wall. + navigation.reset({ index: 0, routes: [{ name: 'Home' }] }); }, [token, navigation]); useNativeLaunchTakeover(); @@ -144,7 +142,7 @@ function Root() { if (booting) return ; return ( - + ); } diff --git a/__tests__/deviceToken.test.ts b/__tests__/deviceToken.test.ts new file mode 100644 index 0000000..c80367e --- /dev/null +++ b/__tests__/deviceToken.test.ts @@ -0,0 +1,137 @@ +/** + * Rev 10: a subscriber is authenticated by a device token, not by an account + * and not by a fake user. The client must send it as `X-Device-Token` on every + * subscriber call — and it must win over an account token when both exist, + * because it is the narrower credential. + */ +import { configureApi, get, post } from '../src/services/apiClient'; +import { ackAlarm, alarmStatus, getAlert, joinAlert, qrImage } from '../src/services/alarmApi'; + +const g = globalThis as unknown as { fetch: typeof fetch }; +const realFetch = g.fetch; + +interface Call { + url: string; + headers: Record; + body?: string; +} + +let calls: Call[] = []; + +function stub(status = 200, body: unknown = {}): void { + calls = []; + g.fetch = (async (url: string, init: RequestInit) => { + calls.push({ + url: String(url), + headers: (init?.headers ?? {}) as Record, + body: typeof init?.body === 'string' ? init.body : undefined, + }); + return { + ok: status >= 200 && status < 300, + status, + text: async () => JSON.stringify(body), + json: async () => body, + }; + }) as unknown as typeof fetch; +} + +beforeEach(() => { + configureApi({ + baseUrl: 'http://instance.test', + token: 'account-token', + refreshToken: null, + onUnauthorized: () => {}, + onSessionUpdate: () => {}, + }); +}); + +afterEach(() => { + g.fetch = realFetch; +}); + +describe('device token transport', () => { + it('is sent as X-Device-Token and replaces the account token', async () => { + stub(200, {}); + await get('/action/getAlert?alertId=a', 'dev-token'); + expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); + expect(calls[0].headers['X-Access-Token']).toBeUndefined(); + }); + + it('is omitted (and the account token used) when not supplied', async () => { + stub(200, {}); + await get('/action/getAlert?alertId=a'); + expect(calls[0].headers['X-Device-Token']).toBeUndefined(); + expect(calls[0].headers['X-Access-Token']).toBe('account-token'); + }); + + it('survives a request with no session at all', async () => { + configureApi({ token: null, refreshToken: null }); + stub(200, {}); + await post('/action/ackAlarm', { alertId: 'a', eventId: 'e' }, false, 'dev-token'); + expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); + expect(calls[0].headers['X-Access-Token']).toBeUndefined(); + }); +}); + +describe('subscriber calls use the device token', () => { + it('getAlert passes it through', async () => { + stub(200, { alertId: 'a' }); + await getAlert('a', 'dev-token'); + expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); + }); + + it('alarmStatus passes it through', async () => { + stub(200, { active: false }); + await alarmStatus('a', undefined, 'dev-token'); + expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); + }); + + it('ackAlarm passes it through and omits deviceId when the token names the row', async () => { + stub(200, { acked: 1 }); + await ackAlarm({ alertId: 'a', eventId: 'e', deviceToken: 'dev-token' }); + expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); + expect(calls[0].url).not.toContain('deviceId='); + }); + + it('qrImage passes it through', async () => { + g.fetch = (async (url: string, init: RequestInit) => { + calls.push({ url: String(url), headers: (init?.headers ?? {}) as Record }); + const blob = { data: 'png' }; + return { + ok: true, + status: 200, + blob: async () => blob, + }; + }) as unknown as typeof fetch; + const RealReader = (globalThis as { FileReader?: unknown }).FileReader; + class FakeReader { + result: string | null = null; + onload: (() => void) | null = null; + onerror: (() => void) | null = null; + readAsDataURL() { + this.result = 'data:image/png;base64,cG5n'; + this.onload?.(); + } + } + (globalThis as { FileReader?: unknown }).FileReader = FakeReader; + try { + await qrImage('a', 'dev-token'); + } finally { + (globalThis as { FileReader?: unknown }).FileReader = RealReader; + } + expect(calls[0].headers['X-Device-Token']).toBe('dev-token'); + }); +}); + +describe('joinAlert', () => { + it('is the unauthenticated path: no access token, and it returns the device token once', async () => { + configureApi({ token: null, refreshToken: null }); + stub(200, { id: 'sub-1', deviceToken: 'issued-once' }); + const joined = await joinAlert({ alertId: 'a', subscribeToken: 's', deviceId: 'd' }); + expect(joined.deviceToken).toBe('issued-once'); + expect(calls[0].headers['X-Access-Token']).toBeUndefined(); + expect(calls[0].headers['X-Device-Token']).toBeUndefined(); + // The payload wrapper the service expects, with no credential fields. + expect(JSON.parse(calls[0].body ?? '{}').payload).toMatchObject({ alertId: 'a', deviceId: 'd' }); + }); +}); diff --git a/src/screens/AlertDetailScreen.tsx b/src/screens/AlertDetailScreen.tsx index 81e739a..8532b23 100644 --- a/src/screens/AlertDetailScreen.tsx +++ b/src/screens/AlertDetailScreen.tsx @@ -255,7 +255,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | setNotice('The service URL is not known yet — re-open the alert in a moment.'); return; } - const wsUrl = alarmSocketUrl(resolved, alertId, token); + const wsUrl = alarmSocketUrl(resolved, alertId, token, alert?.deviceToken); if (!wsUrl) { setNotice( 'No instance address is configured, so background listening is unavailable.', @@ -269,7 +269,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | ? 'Listening again — this device is armed.' : `Could not start listening (${res.reason ?? 'unknown'}).`, ); - }, [alertId, getAlert, refreshListening, resolved]); + }, [alert?.deviceToken, alertId, getAlert, refreshListening, resolved]); const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions( isAdmin ? alertId : null, @@ -279,7 +279,7 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | // Read the authoritative state on mount; the local copy is only a cache. const loadServerAlert = useCallback(async () => { try { - const fresh = await api.getAlert(alertId); + const fresh = await api.getAlert(alertId, alert?.deviceToken); setServerAlert(fresh); updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message }); } catch (e) { @@ -296,7 +296,14 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' | } // Offline is fine — the cached copy renders. } - }, [alert?.expiresAt, alertId, markEnded, serverAlert?.expiresAt, updateAlertLocal]); + }, [ + alert?.deviceToken, + alert?.expiresAt, + alertId, + markEnded, + serverAlert?.expiresAt, + updateAlertLocal, + ]); useEffect(() => { setActiveAlertId(isAdmin ? null : alertId); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index b31fa84..10ab6ff 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -68,7 +68,7 @@ const ENDING_COPY: Record['lastEnding']> export default function HomeScreen() { const navigation = useNavigation