[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:
@@ -132,11 +132,9 @@ function Root() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (token === prevToken.current) return;
|
if (token === prevToken.current) return;
|
||||||
prevToken.current = token;
|
prevToken.current = token;
|
||||||
if (token) {
|
// Home is always reachable: without an account it is the subscriber view
|
||||||
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
|
// (rev 10). Login is an explicit destination, never a wall.
|
||||||
} else {
|
navigation.reset({ index: 0, routes: [{ name: 'Home' }] });
|
||||||
navigation.reset({ index: 0, routes: [{ name: 'Login' }] });
|
|
||||||
}
|
|
||||||
}, [token, navigation]);
|
}, [token, navigation]);
|
||||||
|
|
||||||
useNativeLaunchTakeover();
|
useNativeLaunchTakeover();
|
||||||
@@ -144,7 +142,7 @@ function Root() {
|
|||||||
if (booting) return <LoadingView label="Starting…" />;
|
if (booting) return <LoadingView label="Starting…" />;
|
||||||
return (
|
return (
|
||||||
<AlarmProvider>
|
<AlarmProvider>
|
||||||
<Navigator initialRoute={token ? 'Home' : 'Login'} />
|
<Navigator initialRoute="Home" />
|
||||||
</AlarmProvider>
|
</AlarmProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.');
|
setNotice('The service URL is not known yet — re-open the alert in a moment.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const wsUrl = alarmSocketUrl(resolved, alertId, token);
|
const wsUrl = alarmSocketUrl(resolved, alertId, token, alert?.deviceToken);
|
||||||
if (!wsUrl) {
|
if (!wsUrl) {
|
||||||
setNotice(
|
setNotice(
|
||||||
'No instance address is configured, so background listening is unavailable.',
|
'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.'
|
? 'Listening again — this device is armed.'
|
||||||
: `Could not start listening (${res.reason ?? 'unknown'}).`,
|
: `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(
|
const { subs, loading: subsLoading, error: subsError, reload: reloadSubs } = useSubscriptions(
|
||||||
isAdmin ? alertId : null,
|
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.
|
// Read the authoritative state on mount; the local copy is only a cache.
|
||||||
const loadServerAlert = useCallback(async () => {
|
const loadServerAlert = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const fresh = await api.getAlert(alertId);
|
const fresh = await api.getAlert(alertId, alert?.deviceToken);
|
||||||
setServerAlert(fresh);
|
setServerAlert(fresh);
|
||||||
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
|
updateAlertLocal(alertId, { status: fresh.status, name: fresh.name, message: fresh.message });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -296,7 +296,14 @@ function AlertDetailView({ alertId, role }: { alertId: string; role?: 'admin' |
|
|||||||
}
|
}
|
||||||
// Offline is fine — the cached copy renders.
|
// Offline is fine — the cached copy renders.
|
||||||
}
|
}
|
||||||
}, [alert?.expiresAt, alertId, markEnded, serverAlert?.expiresAt, updateAlertLocal]);
|
}, [
|
||||||
|
alert?.deviceToken,
|
||||||
|
alert?.expiresAt,
|
||||||
|
alertId,
|
||||||
|
markEnded,
|
||||||
|
serverAlert?.expiresAt,
|
||||||
|
updateAlertLocal,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveAlertId(isAdmin ? null : alertId);
|
setActiveAlertId(isAdmin ? null : alertId);
|
||||||
|
|||||||
+49
-14
@@ -68,7 +68,7 @@ const ENDING_COPY: Record<NonNullable<ReturnType<typeof useAlarm>['lastEnding']>
|
|||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const navigation = useNavigation<Nav>();
|
const navigation = useNavigation<Nav>();
|
||||||
const { username, logout } = useAuth();
|
const { username, anonymous, logout } = useAuth();
|
||||||
const { online } = useConnection();
|
const { online } = useConnection();
|
||||||
const {
|
const {
|
||||||
adminAlerts,
|
adminAlerts,
|
||||||
@@ -83,7 +83,9 @@ export default function HomeScreen() {
|
|||||||
clearLastEnding,
|
clearLastEnding,
|
||||||
} = useAlarm();
|
} = 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 [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
const onRefresh = async () => {
|
const onRefresh = async () => {
|
||||||
@@ -102,7 +104,11 @@ export default function HomeScreen() {
|
|||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
|
<AppText style={{ ...typeTitle, color: text }}>Alarm</AppText>
|
||||||
<AppText style={{ ...typeCaption, color: textSecondary }}>
|
<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>
|
</AppText>
|
||||||
</View>
|
</View>
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -131,6 +137,30 @@ export default function HomeScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
) : 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}>
|
<View style={styles.tabs}>
|
||||||
<TabButton
|
<TabButton
|
||||||
label="My alerts"
|
label="My alerts"
|
||||||
@@ -147,6 +177,7 @@ export default function HomeScreen() {
|
|||||||
onPress={() => setTab('subscribed')}
|
onPress={() => setTab('subscribed')}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={styles.content}
|
contentContainerStyle={styles.content}
|
||||||
@@ -241,17 +272,21 @@ export default function HomeScreen() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onPress={() => navigation.navigate('Scanner')}
|
onPress={() => navigation.navigate('Scanner')}
|
||||||
/>
|
/>
|
||||||
<Button
|
{/* A subscriber has no session to end: the device token lives with the
|
||||||
label="Sign out"
|
subscription, so there is nothing to sign out of (rev 10). */}
|
||||||
variant="ghost"
|
{anonymous ? null : (
|
||||||
height={44}
|
<Button
|
||||||
onPress={() =>
|
label="Sign out"
|
||||||
RNAlert.alert('Sign out?', 'You will need your username and password to sign back in.', [
|
variant="ghost"
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
height={44}
|
||||||
{ text: 'Sign out', style: 'destructive', onPress: logout },
|
onPress={() =>
|
||||||
])
|
RNAlert.alert('Sign out?', 'You will need your username and password to sign back in.', [
|
||||||
}
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
/>
|
{ text: 'Sign out', style: 'destructive', onPress: logout },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -319,10 +319,14 @@ export default function SettingsScreen() {
|
|||||||
<Row
|
<Row
|
||||||
icon={Info}
|
icon={Info}
|
||||||
tint={textMuted}
|
tint={textMuted}
|
||||||
title={username ?? 'Not signed in'}
|
title={username ?? 'No account on this device'}
|
||||||
detail={email || 'Signed in on this instance'}
|
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>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
+44
-18
@@ -104,6 +104,12 @@ export interface Alert {
|
|||||||
role?: 'admin' | 'subscriber';
|
role?: 'admin' | 'subscriber';
|
||||||
/** Client-side only: subscription id for the subscriber role. */
|
/** Client-side only: subscription id for the subscriber role. */
|
||||||
subscriptionId?: string;
|
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;
|
deviceLabel?: string;
|
||||||
lastTriggered?: string;
|
lastTriggered?: string;
|
||||||
subscriberCount?: number;
|
subscriberCount?: number;
|
||||||
@@ -165,8 +171,12 @@ export function createAlert(payload: {
|
|||||||
return post<Alert>('/action/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`
|
* revocation handle; there is no `subscriptionId` on the wire and no `alert`
|
||||||
* object (the join screen already holds the QR's alert data).
|
* 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: {
|
export function joinAlert(payload: {
|
||||||
alertId: string;
|
alertId: string;
|
||||||
subscribeToken: string;
|
subscribeToken: string;
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
deviceLabel?: string;
|
deviceLabel?: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
}): Promise<Subscription> {
|
}): Promise<Subscription & { deviceToken?: string }> {
|
||||||
return post<Subscription>('/action/joinAlert', payload);
|
return post<Subscription & { deviceToken?: string }>('/action/joinAlert', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSubscriptions(alertId: string): Promise<Subscription[]> {
|
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 : [];
|
return Array.isArray(items) ? items : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeSubscription(payload: {
|
export function removeSubscription(
|
||||||
alertId: string;
|
payload: { alertId: string; subscriptionId: string },
|
||||||
subscriptionId: string;
|
deviceToken?: string | null,
|
||||||
}): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
return post<unknown>('/action/removeSubscription', payload);
|
return post<unknown>('/action/removeSubscription', payload, false, deviceToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function triggerAlert(payload: {
|
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
|
* 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.
|
* 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)}${
|
const query = `alertId=${encodeURIComponent(alertId)}${
|
||||||
deviceId ? `&deviceId=${encodeURIComponent(deviceId)}` : ''
|
deviceId ? `&deviceId=${encodeURIComponent(deviceId)}` : ''
|
||||||
}`;
|
}`;
|
||||||
try {
|
try {
|
||||||
return await get<AlarmStatus>(`/action/alarmStatus?${query}`);
|
return await get<AlarmStatus>(`/action/alarmStatus?${query}`, deviceToken);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof OfflineAlarmError && e.code === 'ERR_ALARM_TRIGGERED_OFFLINE') {
|
if (e instanceof OfflineAlarmError && e.code === 'ERR_ALARM_TRIGGERED_OFFLINE') {
|
||||||
const frame = offlineFrame(e.body);
|
const frame = offlineFrame(e.body);
|
||||||
@@ -271,12 +291,13 @@ export function isAlertGone(error: unknown): boolean {
|
|||||||
export function ackAlarm(input: {
|
export function ackAlarm(input: {
|
||||||
alertId: string;
|
alertId: string;
|
||||||
eventId: 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> {
|
}): Promise<unknown> {
|
||||||
return post<unknown>(
|
const query = input.deviceId ? `?deviceId=${encodeURIComponent(input.deviceId)}` : '';
|
||||||
`/action/ackAlarm?deviceId=${encodeURIComponent(input.deviceId)}`,
|
return post<unknown>(`/action/ackAlarm${query}`, input, false, input.deviceToken);
|
||||||
input,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deleteAlert(payload: { alertId: string; adminToken: string }): Promise<unknown> {
|
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. */
|
/** Optional convenience: the service renders the PNG; resolved as base64. */
|
||||||
export function qrImage(alertId: string): Promise<string> {
|
export function qrImage(alertId: string, deviceToken?: string | null): Promise<string> {
|
||||||
return requestBase64('GET', `/action/qrImage?alertId=${encodeURIComponent(alertId)}`);
|
return requestBase64(
|
||||||
|
'GET',
|
||||||
|
`/action/qrImage?alertId=${encodeURIComponent(alertId)}`,
|
||||||
|
undefined,
|
||||||
|
deviceToken,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ async function perform<T>(
|
|||||||
payload: unknown,
|
payload: unknown,
|
||||||
raw: boolean,
|
raw: boolean,
|
||||||
bodyMode: BodyMode,
|
bodyMode: BodyMode,
|
||||||
|
deviceToken?: string | null,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
if (!config.baseUrl) {
|
if (!config.baseUrl) {
|
||||||
throw new ApiError(null, 'Server not configured yet');
|
throw new ApiError(null, 'Server not configured yet');
|
||||||
@@ -141,7 +142,11 @@ async function perform<T>(
|
|||||||
|
|
||||||
for (let attempt = 0; attempt < 2; attempt++) {
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
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;
|
headers['X-Access-Token'] = config.token;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,8 +254,9 @@ export async function request<T>(
|
|||||||
path: string,
|
path: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
raw = false,
|
raw = false,
|
||||||
|
deviceToken?: string | null,
|
||||||
): Promise<T> {
|
): 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. */
|
/** Text response variant for endpoints that are not JSON. */
|
||||||
@@ -266,22 +272,32 @@ export async function requestBase64(
|
|||||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
|
||||||
path: string,
|
path: string,
|
||||||
payload?: unknown,
|
payload?: unknown,
|
||||||
|
deviceToken?: string | null,
|
||||||
): Promise<string> {
|
): 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> {
|
export async function get<T>(path: string, deviceToken?: string | null): Promise<T> {
|
||||||
return request<T>('GET', path);
|
return request<T>('GET', path, undefined, false, deviceToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function post<T>(path: string, payload?: unknown, raw?: boolean): Promise<T> {
|
export async function post<T>(
|
||||||
return request<T>('POST', path, payload, raw);
|
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> {
|
export async function put<T>(path: string, payload?: unknown): Promise<T> {
|
||||||
return request<T>('PUT', path, payload);
|
return request<T>('PUT', path, payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function del<T = void>(path: string, payload?: unknown): Promise<T> {
|
export async function del<T = void>(
|
||||||
return request<T>('DELETE', path, payload);
|
path: string,
|
||||||
|
payload?: unknown,
|
||||||
|
deviceToken?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
|
return request<T>('DELETE', path, payload, false, deviceToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,12 @@ export function alarmSocketUrl(
|
|||||||
resolved: { baseUrl: string },
|
resolved: { baseUrl: string },
|
||||||
alertId: string,
|
alertId: string,
|
||||||
subscribeToken: string,
|
subscribeToken: string,
|
||||||
|
deviceToken?: string | null,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!resolved.baseUrl) return null;
|
if (!resolved.baseUrl) return null;
|
||||||
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
|
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}`;
|
return `${ws}/api/${SERVICE_NAME}/ws/alarm?${q}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export interface PendingAck {
|
|||||||
alertId: string;
|
alertId: string;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
deviceId: 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. */
|
/** 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 === 'object' &&
|
||||||
typeof (v as PendingAck).alertId === 'string' &&
|
typeof (v as PendingAck).alertId === 'string' &&
|
||||||
typeof (v as PendingAck).eventId === '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 {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -253,8 +253,11 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
resolved,
|
resolved,
|
||||||
alertId: activeAlert?.alertId ?? null,
|
alertId: activeAlert?.alertId ?? null,
|
||||||
subscribeToken: activeAlert?.subscribeToken ?? 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,
|
deviceId,
|
||||||
|
deviceToken: activeAlert?.deviceToken ?? null,
|
||||||
onTrigger: frame => handleTrigger(frame, true),
|
onTrigger: frame => handleTrigger(frame, true),
|
||||||
onDisarm: frame => handleDisarm(frame, true),
|
onDisarm: frame => handleDisarm(frame, true),
|
||||||
// A 404 from the check-in is terminal: the alert ended. There is nothing to
|
// 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,
|
name: input.alertId,
|
||||||
status: 'armed' as AlertStatus,
|
status: 'armed' as AlertStatus,
|
||||||
subscribeToken: input.subscribeToken,
|
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',
|
role: 'subscriber',
|
||||||
// The wire's subscription view is `id`; the request field name is the
|
// The wire's subscription view is `id`; the request field name is the
|
||||||
// only place `subscriptionId` appears.
|
// only place `subscriptionId` appears.
|
||||||
@@ -344,7 +350,10 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const target = subscribedAlerts.find(a => a.alertId === alertId);
|
const target = subscribedAlerts.find(a => a.alertId === alertId);
|
||||||
if (target?.subscriptionId) {
|
if (target?.subscriptionId) {
|
||||||
try {
|
try {
|
||||||
await api.removeSubscription({ alertId, subscriptionId: target.subscriptionId });
|
await api.removeSubscription(
|
||||||
|
{ alertId, subscriptionId: target.subscriptionId },
|
||||||
|
target.deviceToken,
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Already gone is settled: the alert ended, so there is nothing to
|
// Already gone is settled: the alert ended, so there is nothing to
|
||||||
// leave on the server and nothing to retry.
|
// leave on the server and nothing to retry.
|
||||||
@@ -421,10 +430,13 @@ export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const acknowledgeAlarm = useCallback(
|
const acknowledgeAlarm = useCallback(
|
||||||
async (alertId: string, eventId: string | undefined) => {
|
async (alertId: string, eventId: string | undefined) => {
|
||||||
if (!deviceId || !eventId) return;
|
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();
|
await flushAcks();
|
||||||
},
|
},
|
||||||
[deviceId],
|
[deviceId, subscribedAlerts],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Retry the durable queue on every plausible reconnect point, plus a slow
|
// Retry the durable queue on every plausible reconnect point, plus a slow
|
||||||
|
|||||||
@@ -22,6 +22,12 @@ interface AuthContextValue {
|
|||||||
token: string | null;
|
token: string | null;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
email: 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;
|
booting: boolean;
|
||||||
busy: boolean;
|
busy: boolean;
|
||||||
login: (username: string, password: string) => Promise<void>;
|
login: (username: string, password: string) => Promise<void>;
|
||||||
@@ -95,11 +101,18 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
} 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);
|
if (!cancelled) setBooting(false);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
|
// startAnonymousSession is stable (useCallback with no deps).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [persistTokens]);
|
}, [persistTokens]);
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
@@ -153,6 +166,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
token: session?.token ?? null,
|
token: session?.token ?? null,
|
||||||
username: session?.username ?? null,
|
username: session?.username ?? null,
|
||||||
email: session?.email ?? null,
|
email: session?.email ?? null,
|
||||||
|
anonymous: session === null,
|
||||||
booting,
|
booting,
|
||||||
busy,
|
busy,
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export interface UseAlarmSocketOptions {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** This device's ledger id (the service hashes it to address the debt). */
|
/** This device's ledger id (the service hashes it to address the debt). */
|
||||||
deviceId?: string | null;
|
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. */
|
/** The service answered 404: the alert is destroyed, there is nothing to ring. */
|
||||||
onGone?: () => void;
|
onGone?: () => void;
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,7 @@ export function useAlarmSocket({
|
|||||||
onDisarm,
|
onDisarm,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
deviceId,
|
deviceId,
|
||||||
|
deviceToken,
|
||||||
onGone,
|
onGone,
|
||||||
}: UseAlarmSocketOptions): UseAlarmSocketResult {
|
}: UseAlarmSocketOptions): UseAlarmSocketResult {
|
||||||
const [socketState, setSocketState] = useState<SocketState>('idle');
|
const [socketState, setSocketState] = useState<SocketState>('idle');
|
||||||
@@ -70,8 +73,11 @@ export function useAlarmSocket({
|
|||||||
disarmRef.current = onDisarm;
|
disarmRef.current = onDisarm;
|
||||||
|
|
||||||
const url = useMemo(
|
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`. */
|
/** Capability, not liveness: the URL exists. Liveness is `socketState`. */
|
||||||
@@ -97,7 +103,7 @@ export function useAlarmSocket({
|
|||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!alertId) return;
|
if (!alertId) return;
|
||||||
try {
|
try {
|
||||||
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined));
|
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isAlertGone(e)) onGoneRef.current?.();
|
if (isAlertGone(e)) onGoneRef.current?.();
|
||||||
// A failed status read is not fatal — the socket is the primary path and
|
// A failed status read is not fatal — the socket is the primary path and
|
||||||
@@ -150,7 +156,7 @@ export function useAlarmSocket({
|
|||||||
const tick = async () => {
|
const tick = async () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
try {
|
try {
|
||||||
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined);
|
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined, deviceToken);
|
||||||
if (!cancelled) applyStatus(status);
|
if (!cancelled) applyStatus(status);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isAlertGone(e)) onGoneRef.current?.();
|
if (isAlertGone(e)) onGoneRef.current?.();
|
||||||
|
|||||||
Reference in New Issue
Block a user