[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:
+67
-40
@@ -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)://<any-host>/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' });
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user