[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
+3 -5
View File
@@ -132,11 +132,9 @@ function Root() {
useEffect(() => {
if (token === prevToken.current) return;
prevToken.current = token;
if (token) {
// 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' }] });
} else {
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
}
}, [token, navigation]);
useNativeLaunchTakeover();
@@ -144,7 +142,7 @@ function Root() {
if (booting) return <LoadingView label="Starting…" />;
return (
<AlarmProvider>
<Navigator initialRoute={token ? 'Home' : 'Login'} />
<Navigator initialRoute="Home" />
</AlarmProvider>
);
}
+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' });
});
});
+11 -4
View File
@@ -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);
+38 -3
View File
@@ -68,7 +68,7 @@ const ENDING_COPY: Record<NonNullable<ReturnType<typeof useAlarm>['lastEnding']>
export default function HomeScreen() {
const navigation = useNavigation<Nav>();
const { username, logout } = useAuth();
const { username, anonymous, logout } = useAuth();
const { online } = useConnection();
const {
adminAlerts,
@@ -83,7 +83,9 @@ export default function HomeScreen() {
clearLastEnding,
} = useAlarm();
const [tab, setTab] = useState<Tab>('admin');
// rev 10: a device identity has no alerts of its own; it starts (and stays)
// on the subscriptions tab.
const [tab, setTab] = useState<Tab>(anonymous ? 'subscribed' : 'admin');
const [refreshing, setRefreshing] = useState(false);
const onRefresh = async () => {
@@ -102,7 +104,11 @@ export default function HomeScreen() {
<View style={{ flex: 1 }}>
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
{username ? `Signed in as ${username}` : 'Max-volume alerts for your devices'}
{anonymous
? 'Subscribed alerts on this device — no account needed'
: username
? `Signed in as ${username}`
: 'Max-volume alerts for your devices'}
</AppText>
</View>
<Pressable
@@ -131,6 +137,30 @@ export default function HomeScreen() {
</Pressable>
) : null}
{anonymous ? (
<Pressable
onPress={() => navigation.navigate('Login')}
accessibilityRole="button"
accessibilityLabel="Sign in to create your own alerts"
>
<Card>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: space2 }}>
<ShieldCheck size={20} color={textMuted} />
<View style={{ flex: 1 }}>
<AppText style={{ ...typeBodyStrong, color: text }}>
Sign in to create your own alerts
</AppText>
<AppText style={{ ...typeCaption, color: textSecondary }}>
Receiving alarms needs no account. Creating one does.
</AppText>
</View>
<ChevronRight size={18} color={textMuted} />
</View>
</Card>
</Pressable>
) : null}
{anonymous ? null : (
<View style={styles.tabs}>
<TabButton
label="My alerts"
@@ -147,6 +177,7 @@ export default function HomeScreen() {
onPress={() => setTab('subscribed')}
/>
</View>
)}
<ScrollView
contentContainerStyle={styles.content}
@@ -241,6 +272,9 @@ export default function HomeScreen() {
variant="secondary"
onPress={() => navigation.navigate('Scanner')}
/>
{/* A subscriber has no session to end: the device token lives with the
subscription, so there is nothing to sign out of (rev 10). */}
{anonymous ? null : (
<Button
label="Sign out"
variant="ghost"
@@ -252,6 +286,7 @@ export default function HomeScreen() {
])
}
/>
)}
</View>
</ScrollView>
</View>
+7 -3
View File
@@ -319,10 +319,14 @@ export default function SettingsScreen() {
<Row
icon={Info}
tint={textMuted}
title={username ?? 'Not signed in'}
detail={email || 'Signed in on this instance'}
title={username ?? 'No account on this device'}
detail={
username
? email || 'Signed in on this instance'
: 'Subscribed alerts work without one. Sign in only to create alerts.'
}
/>
<Button label="Sign out" variant="secondary" onPress={logout} />
{username ? <Button label="Sign out" variant="secondary" onPress={logout} /> : null}
</View>
</Card>
+44 -18
View File
@@ -104,6 +104,12 @@ export interface Alert {
role?: 'admin' | 'subscriber';
/** Client-side only: subscription id for the subscriber role. */
subscriptionId?: string;
/**
* Client-side only: the device credential minted for this alert at join
* (rev 10). It never appears in an alert response — it is the subscriber's
* identity, so it only ever lives in this client's storage.
*/
deviceToken?: string;
deviceLabel?: string;
lastTriggered?: string;
subscriberCount?: number;
@@ -165,8 +171,12 @@ export function createAlert(payload: {
return post<Alert>('/action/createAlert', payload);
}
export function getAlert(alertId: string): Promise<Alert> {
return get<Alert>(`/action/getAlert?alertId=${encodeURIComponent(alertId)}`);
/**
* Read an alert. A subscriber passes the device token minted at join (rev 10);
* an account passes nothing and the client attaches its access token.
*/
export function getAlert(alertId: string, deviceToken?: string | null): Promise<Alert> {
return get<Alert>(`/action/getAlert?alertId=${encodeURIComponent(alertId)}`, deviceToken);
}
/**
@@ -174,14 +184,20 @@ export function getAlert(alertId: string): Promise<Alert> {
* revocation handle; there is no `subscriptionId` on the wire and no `alert`
* object (the join screen already holds the QR's alert data).
*/
/**
* Join an alert. This is the UNAUTHENTICATED path (rev 10): no access token is
* involved — the shared subscribe token proves the caller was shown the QR, and
* the service answers with a device token that becomes this device's credential
* for that alert. There is no account and no synthetic user anywhere.
*/
export function joinAlert(payload: {
alertId: string;
subscribeToken: string;
deviceId: string;
deviceLabel?: string;
platform?: string;
}): Promise<Subscription> {
return post<Subscription>('/action/joinAlert', payload);
}): Promise<Subscription & { deviceToken?: string }> {
return post<Subscription & { deviceToken?: string }>('/action/joinAlert', payload);
}
export async function listSubscriptions(alertId: string): Promise<Subscription[]> {
@@ -194,11 +210,11 @@ export async function listSubscriptions(alertId: string): Promise<Subscription[]
return Array.isArray(items) ? items : [];
}
export function removeSubscription(payload: {
alertId: string;
subscriptionId: string;
}): Promise<unknown> {
return post<unknown>('/action/removeSubscription', payload);
export function removeSubscription(
payload: { alertId: string; subscriptionId: string },
deviceToken?: string | null,
): Promise<unknown> {
return post<unknown>('/action/removeSubscription', payload, false, deviceToken);
}
export function triggerAlert(payload: {
@@ -231,12 +247,16 @@ export function disarmAlert(payload: {
* upgrade covers it; this check-in is what covers a socket that was down or an
* app reopened cold, where no replay could reach the native path.
*/
export async function alarmStatus(alertId: string, deviceId?: string): Promise<AlarmStatus> {
export async function alarmStatus(
alertId: string,
deviceId?: string,
deviceToken?: string | null,
): Promise<AlarmStatus> {
const query = `alertId=${encodeURIComponent(alertId)}${
deviceId ? `&deviceId=${encodeURIComponent(deviceId)}` : ''
}`;
try {
return await get<AlarmStatus>(`/action/alarmStatus?${query}`);
return await get<AlarmStatus>(`/action/alarmStatus?${query}`, deviceToken);
} catch (e) {
if (e instanceof OfflineAlarmError && e.code === 'ERR_ALARM_TRIGGERED_OFFLINE') {
const frame = offlineFrame(e.body);
@@ -271,12 +291,13 @@ export function isAlertGone(error: unknown): boolean {
export function ackAlarm(input: {
alertId: string;
eventId: string;
deviceId: string;
/** Account path: the wire still addresses the ledger by (user, device). */
deviceId?: string;
/** Subscriber path: the device token already names the subscription. */
deviceToken?: string | null;
}): Promise<unknown> {
return post<unknown>(
`/action/ackAlarm?deviceId=${encodeURIComponent(input.deviceId)}`,
input,
);
const query = input.deviceId ? `?deviceId=${encodeURIComponent(input.deviceId)}` : '';
return post<unknown>(`/action/ackAlarm${query}`, input, false, input.deviceToken);
}
export function deleteAlert(payload: { alertId: string; adminToken: string }): Promise<unknown> {
@@ -284,8 +305,13 @@ export function deleteAlert(payload: { alertId: string; adminToken: string }): P
}
/** Optional convenience: the service renders the PNG; resolved as base64. */
export function qrImage(alertId: string): Promise<string> {
return requestBase64('GET', `/action/qrImage?alertId=${encodeURIComponent(alertId)}`);
export function qrImage(alertId: string, deviceToken?: string | null): Promise<string> {
return requestBase64(
'GET',
`/action/qrImage?alertId=${encodeURIComponent(alertId)}`,
undefined,
deviceToken,
);
}
// ── Helpers ──────────────────────────────────────────────────────────────────
+25 -9
View File
@@ -124,6 +124,7 @@ async function perform<T>(
payload: unknown,
raw: boolean,
bodyMode: BodyMode,
deviceToken?: string | null,
): Promise<T> {
if (!config.baseUrl) {
throw new ApiError(null, 'Server not configured yet');
@@ -141,7 +142,11 @@ async function perform<T>(
for (let attempt = 0; attempt < 2; attempt++) {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (config.token) {
// A device token is a narrower credential than an account token (contract
// rev 10): when a call carries one, that is the identity the service sees.
if (deviceToken) {
headers['X-Device-Token'] = deviceToken;
} else if (config.token) {
headers['X-Access-Token'] = config.token;
}
@@ -249,8 +254,9 @@ export async function request<T>(
path: string,
payload?: unknown,
raw = false,
deviceToken?: string | null,
): Promise<T> {
return perform<T>(method, path, payload, raw, 'json');
return perform<T>(method, path, payload, raw, 'json', deviceToken);
}
/** Text response variant for endpoints that are not JSON. */
@@ -266,22 +272,32 @@ export async function requestBase64(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
payload?: unknown,
deviceToken?: string | null,
): Promise<string> {
return perform<string>(method, path, payload, false, 'base64');
return perform<string>(method, path, payload, false, 'base64', deviceToken);
}
export async function get<T>(path: string): Promise<T> {
return request<T>('GET', path);
export async function get<T>(path: string, deviceToken?: string | null): Promise<T> {
return request<T>('GET', path, undefined, false, deviceToken);
}
export async function post<T>(path: string, payload?: unknown, raw?: boolean): Promise<T> {
return request<T>('POST', path, payload, raw);
export async function post<T>(
path: string,
payload?: unknown,
raw?: boolean,
deviceToken?: string | null,
): Promise<T> {
return request<T>('POST', path, payload, raw, deviceToken);
}
export async function put<T>(path: string, payload?: unknown): Promise<T> {
return request<T>('PUT', path, payload);
}
export async function del<T = void>(path: string, payload?: unknown): Promise<T> {
return request<T>('DELETE', path, payload);
export async function del<T = void>(
path: string,
payload?: unknown,
deviceToken?: string | null,
): Promise<T> {
return request<T>('DELETE', path, payload, false, deviceToken);
}
+4 -1
View File
@@ -47,9 +47,12 @@ export function alarmSocketUrl(
resolved: { baseUrl: string },
alertId: string,
subscribeToken: string,
deviceToken?: string | null,
): string | null {
if (!resolved.baseUrl) return null;
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`;
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}${
deviceToken ? `&deviceToken=${encodeURIComponent(deviceToken)}` : ''
}`;
return `${ws}/api/${SERVICE_NAME}/ws/alarm?${q}`;
}
+5 -1
View File
@@ -21,6 +21,8 @@ export interface PendingAck {
alertId: string;
eventId: string;
deviceId: string;
/** Subscriber credential for the alert (rev 10); absent for accounts. */
deviceToken?: string;
}
/** Merge by (alertId, eventId); a repeat dismiss never duplicates an entry. */
@@ -41,7 +43,9 @@ function parse(raw: string | null): PendingAck[] {
typeof v === 'object' &&
typeof (v as PendingAck).alertId === 'string' &&
typeof (v as PendingAck).eventId === 'string' &&
typeof (v as PendingAck).deviceId === 'string',
typeof (v as PendingAck).deviceId === 'string' &&
((v as PendingAck).deviceToken === undefined ||
typeof (v as PendingAck).deviceToken === 'string'),
);
} catch {
return [];
+16 -4
View File
@@ -253,8 +253,11 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
resolved,
alertId: activeAlert?.alertId ?? null,
subscribeToken: activeAlert?.subscribeToken ?? null,
enabled: !!token && online,
// A subscriber needs no session: the device token minted at join is the
// credential (rev 10).
enabled: (!!token || !!activeAlert?.deviceToken) && online,
deviceId,
deviceToken: activeAlert?.deviceToken ?? null,
onTrigger: frame => handleTrigger(frame, true),
onDisarm: frame => handleDisarm(frame, true),
// A 404 from the check-in is terminal: the alert ended. There is nothing to
@@ -323,6 +326,9 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
name: input.alertId,
status: 'armed' as AlertStatus,
subscribeToken: input.subscribeToken,
// The service minted this credential for THIS device and alert; it is
// returned exactly once, on the join that created (or rotated) it.
deviceToken: joined.deviceToken,
role: 'subscriber',
// The wire's subscription view is `id`; the request field name is the
// only place `subscriptionId` appears.
@@ -344,7 +350,10 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
const target = subscribedAlerts.find(a => a.alertId === alertId);
if (target?.subscriptionId) {
try {
await api.removeSubscription({ alertId, subscriptionId: target.subscriptionId });
await api.removeSubscription(
{ alertId, subscriptionId: target.subscriptionId },
target.deviceToken,
);
} catch (e) {
// Already gone is settled: the alert ended, so there is nothing to
// leave on the server and nothing to retry.
@@ -421,10 +430,13 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
const acknowledgeAlarm = useCallback(
async (alertId: string, eventId: string | undefined) => {
if (!deviceId || !eventId) return;
await enqueueAck({ alertId, eventId, deviceId });
// A subscriber acks with the device token it joined with; an account acks
// with its session token (the api client picks the right header).
const deviceToken = subscribedAlerts.find(a => a.alertId === alertId)?.deviceToken;
await enqueueAck({ alertId, eventId, deviceId, deviceToken });
await flushAcks();
},
[deviceId],
[deviceId, subscribedAlerts],
);
// Retry the durable queue on every plausible reconnect point, plus a slow
+14
View File
@@ -22,6 +22,12 @@ interface AuthContextValue {
token: string | null;
username: string | null;
email: string | null;
/**
* No account on this device: subscriber-only surface. Receiving alarms never
* needs one (rev 10) — a subscriber is identified by the device token that
* `joinAlert` hands out, not by a user.
*/
anonymous: boolean;
booting: boolean;
busy: boolean;
login: (username: string, password: string) => Promise<void>;
@@ -95,11 +101,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
.catch(() => {});
}
} catch {}
if (cancelled) return;
// No account: come up as a subscriber. Rev 10 needs no identity at boot —
// the device token arrives with the first join, and the app is usable from
// the store without a login.
if (!cancelled) setBooting(false);
})();
return () => {
cancelled = true;
};
// startAnonymousSession is stable (useCallback with no deps).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [persistTokens]);
const login = useCallback(
@@ -153,6 +166,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
token: session?.token ?? null,
username: session?.username ?? null,
email: session?.email ?? null,
anonymous: session === null,
booting,
busy,
login,
+10 -4
View File
@@ -24,6 +24,8 @@ export interface UseAlarmSocketOptions {
enabled?: boolean;
/** This device's ledger id (the service hashes it to address the debt). */
deviceId?: string | null;
/** Subscriber credential for this alert (rev 10). Replaces the access token. */
deviceToken?: string | null;
/** The service answered 404: the alert is destroyed, there is nothing to ring. */
onGone?: () => void;
}
@@ -56,6 +58,7 @@ export function useAlarmSocket({
onDisarm,
enabled = true,
deviceId,
deviceToken,
onGone,
}: UseAlarmSocketOptions): UseAlarmSocketResult {
const [socketState, setSocketState] = useState<SocketState>('idle');
@@ -70,8 +73,11 @@ export function useAlarmSocket({
disarmRef.current = onDisarm;
const url = useMemo(
() => (resolved && alertId && subscribeToken ? alarmSocketUrl(resolved, alertId, subscribeToken) : null),
[resolved, alertId, subscribeToken],
() =>
resolved && alertId && subscribeToken
? alarmSocketUrl(resolved, alertId, subscribeToken, deviceToken)
: null,
[resolved, alertId, subscribeToken, deviceToken],
);
/** Capability, not liveness: the URL exists. Liveness is `socketState`. */
@@ -97,7 +103,7 @@ export function useAlarmSocket({
const refresh = useCallback(async () => {
if (!alertId) return;
try {
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined));
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken));
} catch (e) {
if (isAlertGone(e)) onGoneRef.current?.();
// A failed status read is not fatal — the socket is the primary path and
@@ -150,7 +156,7 @@ export function useAlarmSocket({
const tick = async () => {
if (cancelled) return;
try {
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined);
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken);
if (!cancelled) applyStatus(status);
} catch (e) {
if (isAlertGone(e)) onGoneRef.current?.();