diff --git a/__tests__/joinError.test.ts b/__tests__/joinError.test.ts index aa022e1..8b3107d 100644 --- a/__tests__/joinError.test.ts +++ b/__tests__/joinError.test.ts @@ -19,9 +19,7 @@ function stubFetch(body: string, status = 400): void { beforeEach(() => { configureApi({ - baseUrl: 'http://alarm.test', - mode: 'direct', - serviceRef: null, + baseUrl: 'http://instance.test', token: null, refreshToken: null, onUnauthorized: () => {}, @@ -59,13 +57,15 @@ describe('apiClient error unwrapping', () => { }); describe('joinErrorMessage', () => { - it('renders a token problem as a remedy, never as "expired"', () => { - // JoinAlert returns this one code for a rotated token AND for a malformed - // one, so the client cannot say which happened. + it('says the code is not valid for this alert, without inventing a replacement', () => { + // JoinAlert returns this one code for any code that does not belong to the + // alert, so the client cannot say which happened — and with rotation gone + // there is no "new one" to ask for. const err = new ApiError(400, 'invalid subscribe token', 'ERR_ALERT_TOKEN_INVALID'); const copy = joinErrorMessage(err); - expect(copy).toBe('This code is no longer valid — ask the owner for a new one.'); + expect(copy).toBe('This code is not valid for this alert.'); expect(copy).not.toMatch(/expired/i); + expect(copy).not.toMatch(/new one|rotate|rotated/i); }); it('passes through any other server message', () => { diff --git a/__tests__/qrPayload.test.ts b/__tests__/qrPayload.test.ts index 653164f..d35988d 100644 --- a/__tests__/qrPayload.test.ts +++ b/__tests__/qrPayload.test.ts @@ -1,6 +1,10 @@ /** - * QR payload parser tests — the frozen payload contract. - * https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken} + * QR payload tests — instance-relative contract. + * + * The service mints `/api/alarm/a/{alertId}#{token}` (no host) and the client + * composes `{configuredOrigin}{qrPayload}`. The parser matches path shape + + * fragment, never host, so legacy `alarm.dcntrlized.org` codes and other + * instances' codes still work. */ import { buildQrPayload, @@ -9,98 +13,96 @@ import { parseQrPayload, qrErrorMessage, truncateToken, - QR_HOST, } from '../src/lib/qrPayload'; const ID = 'alr_7f3c9a1b2d'; const TOKEN = 'sub_9Qx2mZp4Lk7VeR1sTn0'; +const ORIGIN = 'https://instance.example'; describe('buildQrPayload', () => { - it('renders the canonical contract payload', () => { - expect(buildQrPayload(ID, TOKEN)).toBe(`https://${QR_HOST}/a/${ID}#${TOKEN}`); + it('composes the service path under the configured origin', () => { + expect(buildQrPayload(ID, TOKEN, ORIGIN)).toBe(`${ORIGIN}/api/alarm/a/${ID}#${TOKEN}`); }); - it('renders the custom-scheme variant', () => { + it('strips a trailing slash on the origin', () => { + expect(buildQrPayload(ID, TOKEN, `${ORIGIN}/`)).toBe(`${ORIGIN}/api/alarm/a/${ID}#${TOKEN}`); + }); + + it('renders a host-free relative path when no origin is configured', () => { + expect(buildQrPayload(ID, TOKEN)).toBe(`/api/alarm/a/${ID}#${TOKEN}`); + }); + + it('keeps the pre-existing scheme builder for old codes but mints no new host', () => { expect(buildQrSchemePayload(ID, TOKEN)).toBe(`dcntrlized-alarm://a/${ID}#${TOKEN}`); }); }); -describe('parseQrPayload', () => { - it('parses the canonical payload', () => { - const res = parseQrPayload(buildQrPayload(ID, TOKEN)); +describe('parseQrPayload — path shape, never host', () => { + it('parses the composed payload', () => { + const res = parseQrPayload(buildQrPayload(ID, TOKEN, ORIGIN)); expect(res).toEqual({ ok: true, payload: { alertId: ID, subscribeToken: TOKEN, source: 'url' }, }); }); - it('parses the custom scheme', () => { - const res = parseQrPayload(buildQrSchemePayload(ID, TOKEN)); + it('accepts a relative /api/alarm/a/{id}#{token}', () => { + const res = parseQrPayload(`/api/alarm/a/${ID}#${TOKEN}`); expect(res.ok && res.payload.alertId).toBe(ID); - expect(res.ok && res.payload.subscribeToken).toBe(TOKEN); - expect(res.ok && res.payload.source).toBe('scheme'); - }); - - it('accepts a relative a/{id}#{token} payload', () => { - const res = parseQrPayload(`a/${ID}#${TOKEN}`); expect(res.ok && res.payload.source).toBe('relative'); - expect(res.ok && res.payload.alertId).toBe(ID); }); - it('accepts a raw {id}#{token} payload (what the owner reads off the panel)', () => { - const res = parseQrPayload(`${ID}#${TOKEN}`); - expect(res.ok && res.payload.source).toBe('raw'); - expect(res.ok && res.payload.subscribeToken).toBe(TOKEN); - }); - - it('tolerates surrounding whitespace and a trailing slash', () => { - const res = parseQrPayload(` https://${QR_HOST}/a/${ID}/#${TOKEN} `); - expect(res.ok && res.payload.alertId).toBe(ID); - }); - - it('tolerates query noise before the fragment', () => { - const res = parseQrPayload(`https://${QR_HOST}/a/${ID}?utm=x#${TOKEN}`); + it('accepts a legacy https://alarm.dcntrlized.org/a/{id}#{token}', () => { + const res = parseQrPayload(`https://alarm.dcntrlized.org/a/${ID}#${TOKEN}`); expect(res.ok && res.payload.alertId).toBe(ID); expect(res.ok && res.payload.subscribeToken).toBe(TOKEN); }); - it('accepts a subdomain of the alarm host (staging)', () => { - const res = parseQrPayload(`https://staging.alarm.dcntrlized.org/a/${ID}#${TOKEN}`); - expect(res.ok).toBe(true); + it('accepts a full http(s) URL on any host (host grants nothing)', () => { + expect(parseQrPayload(`https://evil.example.com/a/${ID}#${TOKEN}`).ok).toBe(true); + expect(parseQrPayload(`http://127.0.0.1:8080/api/alarm/a/${ID}#${TOKEN}`).ok).toBe(true); + expect(parseQrPayload(`https://staging.alarm.dcntrlized.org/a/${ID}#${TOKEN}`).ok).toBe(true); }); - it('accepts localhost / LAN dev payloads', () => { - expect(parseQrPayload(`http://127.0.0.1:8088/a/${ID}#${TOKEN}`).ok).toBe(true); - expect(parseQrPayload(`http://192.168.1.232:8088/a/${ID}#${TOKEN}`).ok).toBe(true); - }); - - it('rejects a foreign host', () => { - expect(parseQrPayload(`https://evil.example.com/a/${ID}#${TOKEN}`)).toEqual({ + it('rejects a URL whose path is not an alarm path', () => { + expect(parseQrPayload(`https://example.com/foo/bar#${TOKEN}`)).toEqual({ ok: false, error: 'not-an-alarm-code', }); }); it('rejects a foreign scheme', () => { - expect(parseQrPayload(`ftp://${QR_HOST}/a/${ID}#${TOKEN}`)).toEqual({ + expect(parseQrPayload(`ftp://example.com/a/${ID}#${TOKEN}`)).toEqual({ ok: false, error: 'not-an-alarm-code', }); }); - it('reports an empty payload', () => { - expect(parseQrPayload(' ')).toEqual({ ok: false, error: 'empty' }); + it('parses the pre-existing custom scheme', () => { + const res = parseQrPayload(buildQrSchemePayload(ID, TOKEN)); + expect(res.ok && res.payload.source).toBe('scheme'); + expect(res.ok && res.payload.alertId).toBe(ID); }); - it('reports a missing token', () => { - expect(parseQrPayload(`https://${QR_HOST}/a/${ID}`)).toEqual({ + it('accepts a relative a/{id}#{token} and raw {id}#{token}', () => { + expect(parseQrPayload(`a/${ID}#${TOKEN}`).ok).toBe(true); + const raw = parseQrPayload(`${ID}#${TOKEN}`); + expect(raw.ok && raw.payload.source).toBe('raw'); + }); + + it('tolerates whitespace, a trailing slash and query noise', () => { + const res = parseQrPayload(` https://alarm.dcntrlized.org/a/${ID}/?utm=x#${TOKEN} `); + expect(res.ok && res.payload.alertId).toBe(ID); + expect(res.ok && res.payload.subscribeToken).toBe(TOKEN); + }); + + it('reports empty, missing token and missing alert id', () => { + expect(parseQrPayload(' ')).toEqual({ ok: false, error: 'empty' }); + expect(parseQrPayload(`https://alarm.dcntrlized.org/a/${ID}`)).toEqual({ ok: false, error: 'missing-token', }); - }); - - it('reports a missing alert id', () => { - expect(parseQrPayload(`https://${QR_HOST}/a/#${TOKEN}`)).toEqual({ + expect(parseQrPayload(`https://alarm.dcntrlized.org/a/#${TOKEN}`)).toEqual({ ok: false, error: 'missing-alert-id', }); @@ -114,23 +116,21 @@ describe('parseQrPayload', () => { it('round-trips ids that need encoding', () => { const weird = 'alr/with space'; - const built = buildQrPayload(weird, TOKEN); - const res = parseQrPayload(built); + const res = parseQrPayload(buildQrPayload(weird, TOKEN, ORIGIN)); expect(res.ok && res.payload.alertId).toBe(weird); }); }); describe('parseManualToken', () => { it('accepts a bare token when the alert id is known', () => { - const res = parseManualToken(TOKEN, ID); - expect(res).toEqual({ + expect(parseManualToken(TOKEN, ID)).toEqual({ ok: true, payload: { alertId: ID, subscribeToken: TOKEN, source: 'raw' }, }); }); it('accepts a full payload even when an id is also supplied', () => { - const res = parseManualToken(buildQrPayload(ID, TOKEN), 'other'); + const res = parseManualToken(buildQrPayload(ID, TOKEN, ORIGIN), 'other'); expect(res.ok && res.payload.alertId).toBe(ID); }); diff --git a/src/lib/qrPayload.ts b/src/lib/qrPayload.ts index 85c866b..ad5e9df 100644 --- a/src/lib/qrPayload.ts +++ b/src/lib/qrPayload.ts @@ -1,25 +1,33 @@ /** * QR payload contract for the Alarm client. * - * Canonical payload (frozen contract): - * https://alarm.dcntrlized.org/a/{alertId}#{subscribeToken} + * The payload is **instance-relative**: the service mints + * `/api/alarm/a/{alertId}#{subscribeToken}` with no host, and the client renders + * it against the origin it already knows (`{configuredOrigin}{qrPayload}`). A + * code therefore stays valid wherever the alert is hosted and can never point at + * a hostname that does not exist. * - * Also accepted: - * - the same URL with a trailing slash / query noise, - * - a bare custom scheme: `dcntrlized-alarm://a/{alertId}#{token}`, - * - a relative `a/{alertId}#{token}`, - * - raw `{alertId}#{token}` (what the owner reads off the QR panel), - * - a pasted token alone when the caller already knows the alertId - * (manual fallback) — see `parseManualToken`. + * The parser matches on **path shape + fragment, never on host**, precisely so + * old printed codes keep working. Accepted: + * - a full `http(s):///api/alarm/a/{alertId}#{token}`, + * - the legacy `https://alarm.dcntrlized.org/a/{alertId}#{token}`, + * - a relative `/api/alarm/a/{alertId}#{token}` or `/a/{alertId}#{token}`, + * - `a/{alertId}#{token}` and raw `{alertId}#{token}` (what the owner reads + * off the QR panel), + * - the pre-existing `dcntrlized-alarm://a/{alertId}#{token}` deep link (kept + * only for codes already in the wild; no new scheme is minted), + * - query noise and a trailing slash, + * - a pasted token alone when the caller already knows the alertId — see + * `parseManualToken`. * * This module is intentionally pure (no React, no RN imports) so it is testable * under jest without a native environment, and so the same parser is used by * the scanner, the deep-link handler and the manual paste field. */ -export const QR_HOST = 'alarm.dcntrlized.org'; export const QR_SCHEME = 'dcntrlized-alarm'; -export const QR_PATH_PREFIX = '/a/'; +/** Path the service mints (and the client composes under its origin). */ +export const QR_PATH_PREFIX = '/api/alarm/a/'; export interface QrPayload { alertId: string; @@ -41,9 +49,21 @@ export type QrParseResult = const OK = (payload: QrPayload): QrParseResult => ({ ok: true, payload }); const FAIL = (error: QrParseError): QrParseResult => ({ ok: false, error }); -/** Canonical payload an owner renders into a QR code. */ -export function buildQrPayload(alertId: string, subscribeToken: string): string { - return `https://${QR_HOST}${QR_PATH_PREFIX}${encodeURIComponent(alertId)}#${subscribeToken}`; +/** + * Payload an owner renders into a QR code. + * + * `origin` is the configured instance origin (e.g. `http://127.0.0.1:8080`), so + * the QR is `{origin}/api/alarm/a/{id}#{token}`. With no origin configured the + * relative service path is rendered instead — still valid, still host-free, and + * still parseable by every client. + */ +export function buildQrPayload( + alertId: string, + subscribeToken: string, + origin = '', +): string { + const base = origin.trim().replace(/\/+$/, ''); + return `${base}${QR_PATH_PREFIX}${encodeURIComponent(alertId)}#${subscribeToken}`; } /** Custom-scheme form, used by deep links and by the "share" affordance. */ @@ -62,18 +82,17 @@ function stripQuery(value: string): string { } /** - * Extract the alert id from a path. `/a/{id}` is the contract path; the id is - * whatever follows the `a/` prefix, so a trailing slash yields "" (missing) - * rather than the literal "a". + * Extract the alert id from a path. The shape is `…/a/{id}` (`/a/{id}` legacy, + * `/api/alarm/a/{id}` current); the segment after the last `a` is the id, so a + * missing id yields "" rather than the literal "a". A path with no `a` segment + * is not an alarm path even if it has a final segment. */ function alertIdFromPath(path: string): string { const trimmed = path.replace(/^\/+/, '').replace(/\/+$/, ''); if (!trimmed) return ''; const segments = trimmed.split('/').filter(Boolean); - if (segments[0] === 'a') { - return segments.length > 1 ? segments[1] : ''; - } - return segments[segments.length - 1] ?? ''; + const at = segments.lastIndexOf('a'); + return at !== -1 && at + 1 < segments.length ? segments[at + 1] : ''; } /** @@ -94,23 +113,23 @@ export function parseQrPayload(raw: string): QrParseResult { return FAIL('not-an-alarm-code'); } const rest = withoutQuery.slice(schemeMatch[0].length); - // For http(s) the first segment is the authority (`host[:port]`); for the - // custom scheme `rest` is already a path. + // For http(s) the first segment is the authority (`host[:port]`) and the + // host grants nothing — **match the path shape, never the host**, so a code + // minted on one instance keeps working on another and legacy + // `alarm.dcntrlized.org` codes still parse. let path: string; if (scheme === 'http' || scheme === 'https') { const slash = rest.indexOf('/'); - const authority = (slash === -1 ? rest : rest.slice(0, slash)).split(':')[0].toLowerCase(); - const hostOk = - authority === QR_HOST || - authority.endsWith(`.${QR_HOST}`) || - authority === 'localhost' || - authority === '127.0.0.1' || - /^\d{1,3}(\.\d{1,3}){3}$/.test(authority); - if (!hostOk) return FAIL('not-an-alarm-code'); path = slash === -1 ? '' : rest.slice(slash); } else { path = rest.startsWith('/') ? rest : `/${rest}`; } + // A URL with a path that has no `/a/` segment is not an alarm code; an + // alarm-shaped path whose id segment is empty is a specific failure. + const alarmPath = /(^|\/)a(\/|$)/.test(path); + if (!alarmPath) { + return path.replace(/^\/+/, '') ? FAIL('not-an-alarm-code') : FAIL('missing-alert-id'); + } const seg = alertIdFromPath(path); if (!seg) return FAIL('missing-alert-id'); if (!token) return FAIL('missing-token'); @@ -121,17 +140,25 @@ export function parseQrPayload(raw: string): QrParseResult { }); } - // No scheme: accept `a/{id}#{token}` or `{id}#{token}`. + // No scheme: `api/alarm/a/{id}#{token}`, `a/{id}#{token}`, or raw + // `{id}#{token}` (what the owner reads off the panel). const cleaned = withoutQuery.replace(/^\/+/, ''); - const seg = alertIdFromPath(cleaned); - if (!seg) return FAIL('missing-alert-id'); + const looksRelative = /(^|\/)a\//.test(cleaned); + if (looksRelative) { + const seg = alertIdFromPath(cleaned); + if (!seg) return FAIL('missing-alert-id'); + if (!token) return FAIL('missing-token'); + return OK({ + alertId: decodeURIComponent(seg), + subscribeToken: token, + source: 'relative', + }); + } + // A bare id is the only non-path form; anything with a slash that is not an + // alarm path is refused rather than guessed at. + if (cleaned.includes('/') || !cleaned) return FAIL('not-an-alarm-code'); if (!token) return FAIL('missing-token'); - const looksRelative = cleaned.startsWith('a/') || cleaned.startsWith(`${QR_SCHEME}/`); - return OK({ - alertId: decodeURIComponent(seg), - subscribeToken: token, - source: looksRelative ? 'relative' : 'raw', - }); + return OK({ alertId: decodeURIComponent(cleaned), subscribeToken: token, source: 'raw' }); } /** diff --git a/src/screens/QrDisplayScreen.tsx b/src/screens/QrDisplayScreen.tsx index 13799e6..7c60a61 100644 --- a/src/screens/QrDisplayScreen.tsx +++ b/src/screens/QrDisplayScreen.tsx @@ -1,13 +1,13 @@ import React, { useMemo, useState } from 'react'; -import { Modal, Pressable, ScrollView, StyleSheet, View } from 'react-native'; +import { Pressable, ScrollView, StyleSheet, View } from 'react-native'; import { useNavigation, useRoute, type RouteProp } from '@react-navigation/native'; import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { Copy, Info, RefreshCw, SunMedium } from 'lucide-react-native'; +import { Copy, Info, SunMedium } from 'lucide-react-native'; import Clipboard from '@react-native-clipboard/clipboard'; import { QrCanvas } from '../components/QrCanvas'; import { AppText, Button, Card, Header } from '../components/ui'; import { useAlarm } from '../state/AlarmContext'; -import * as api from '../services/alarmApi'; +import { useConnection } from '../state/ConnectionContext'; import { buildQrPayload, truncateToken } from '../lib/qrPayload'; import type { RootStackParamList } from '../navigation'; import { @@ -21,19 +21,16 @@ import { space3, space4, space6, - stateAlarm, surface, surfaceRaised, text, textFaint, textSecondary, - typeBody, typeCaption, typeCode, typeHeading, typeLabel, typeMicro, - typeTitle, } from '../theme/tokens'; type Nav = NativeStackNavigationProp; @@ -47,27 +44,31 @@ type Rt = RouteProp; * Brightness is **not** raised: there is no brightness dependency in the tree, * so the hint tells the user to raise it themselves. The spec's "brightness is * raised" claim was removed rather than shipped as a false affordance (D-11). + * + * There is no token rotation: subscriptions are permanent for the life of the + * alert (product ruling), and revoking a device or deleting the alert are the + * only lifecycle controls. The rotation flow is deleted, not hidden. */ export default function QrDisplayScreen() { const navigation = useNavigation