[feat](client): join without an account; the token the join returns is the identity

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).
This commit is contained in:
2026-10-19 18:41:49 +01:00
parent 020a90f0d3
commit 31cff32824
12 changed files with 326 additions and 64 deletions
+137
View File
@@ -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<string, string>;
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<string, string>,
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<string, string> });
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' });
});
});