[feat](client): scan an instance-relative join payload

The service mints /api/alarm/a/{alertId}#{token} with no host, so the QR renders against the configured instance and keeps working when the instance moves or is reached by another name. The scanner now matches path shape and fragment and never the host, so legacy alarm.dcntrlized.org codes and other instances codes still join; error copy distinguishes a malformed code from a wrong-alert token.
This commit is contained in:
2026-10-19 02:34:20 +01:00
parent fc66b8e088
commit dcfc9a5df8
5 changed files with 163 additions and 202 deletions
+7 -7
View File
@@ -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', () => {
+55 -55
View File
@@ -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);
});