From a53d6964333644d3ce5ef8b2b7cb307ca73cd11c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Valent=C3=ADn=20Quiroz=20de=20la=20Sierra?= <85706426+vqsierra@users.noreply.github.com> Date: Fri, 13 Nov 2026 12:24:42 -0500 Subject: [PATCH] Remove duplicated checks and constants across the packages No behavior, wire-format or public API change. - sealed-envelope: one P-256 constant in util.ts - relay: one admin-key check (asAdmin and DELETE) and one write-refusal check (draft and submission) - session-client: shared ID, secret and app patterns; one bad-link failure; one mapping from a network error to a saved-on-device result - ui: one definition of the touch button size --- packages/relay/src/handler.ts | 18 +++++++++++------- packages/sealed-envelope/src/envelope.ts | 3 +-- packages/sealed-envelope/src/keys.ts | 3 +-- packages/sealed-envelope/src/util.ts | 2 ++ packages/session-client/src/file.ts | 4 +--- packages/session-client/src/link.ts | 8 ++++---- packages/session-client/src/participant.ts | 22 +++++++++++++--------- packages/session-client/src/patterns.ts | 4 ++++ packages/ui/src/controls.tsx | 7 +++++-- 9 files changed, 42 insertions(+), 29 deletions(-) create mode 100644 packages/session-client/src/patterns.ts diff --git a/packages/relay/src/handler.ts b/packages/relay/src/handler.ts index 9545058..cdeed18 100644 --- a/packages/relay/src/handler.ts +++ b/packages/relay/src/handler.ts @@ -94,10 +94,14 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis fail(403, "FORBIDDEN", "This creation key is not valid."); } + async function checkAdminToken(token: string, s: SessionRow) { + if (!safeEqual(await sha256(token), s.adminHash)) fail(403, "FORBIDDEN", "This management key is not valid."); + } + async function asAdmin(request: Request, sessionId: string): Promise { const token = bearer(request); const s = await loadSession(sessionId); - if (!safeEqual(await sha256(token), s.adminHash)) fail(403, "FORBIDDEN", "This management key is not valid."); + await checkAdminToken(token, s); return s; } @@ -191,7 +195,7 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis const token = bearer(request); const s = await store.getSession(sessionId); if (!s) return empty(); - if (!safeEqual(await sha256(token), s.adminHash)) fail(403, "FORBIDDEN", "This management key is not valid."); + await checkAdminToken(token, s); await store.deleteSession(sessionId); return empty(); } @@ -221,7 +225,8 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis }); } - const refuseDraft = (s: SessionRow, r: SlotRow) => { + // Nothing more can be written once a response is submitted or the session is closed. + const refuseWrite = (s: SessionRow, r: SlotRow) => { if (r.submission !== null) fail(409, "ALREADY_SUBMITTED", "This response has already been submitted."); if (s.closedAt !== null) fail(409, "SESSION_CLOSED", "This activity is closed."); }; @@ -231,13 +236,13 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis const { data } = await readBody(request, bodyLimit); const envelope = envelopeField(data.envelope, limits.maxEnvelopeBytes); const baseVersion = intField(data.baseVersion, 0, Number.MAX_SAFE_INTEGER, "baseVersion"); - refuseDraft(session, slot); + refuseWrite(session, slot); const updatedAt = clock().toISOString(); if (await store.writeDraft(sessionId, slotId, envelope, baseVersion, updatedAt)) return json({ draftVersion: baseVersion + 1, updatedAt }); const [s, r] = await Promise.all([store.getSession(sessionId), store.getSlot(sessionId, slotId)]); if (!s || !r) return notFound(); - refuseDraft(s, r); + refuseWrite(s, r); return fail(409, "VERSION_CONFLICT", "This response changed on another device.", { draftVersion: r.draftVersion }); } @@ -249,8 +254,7 @@ export function createRelay(options: RelayOptions): (request: Request) => Promis // An identical retry replays the first result, even after the session closes. const settled = (s: SessionRow, r: SlotRow) => { if (r.submissionHash !== null && safeEqual(r.submissionHash, hash)) return json({ submittedAt: r.submittedAt }); - if (r.submission !== null) fail(409, "ALREADY_SUBMITTED", "This response has already been submitted."); - if (s.closedAt !== null) fail(409, "SESSION_CLOSED", "This activity is closed."); + refuseWrite(s, r); return null; }; const early = settled(session, slot); diff --git a/packages/sealed-envelope/src/envelope.ts b/packages/sealed-envelope/src/envelope.ts index 3aa6d60..42056fd 100644 --- a/packages/sealed-envelope/src/envelope.ts +++ b/packages/sealed-envelope/src/envelope.ts @@ -1,4 +1,4 @@ -import { decoder, encoder, fail, fromBase64url, randomBytes, toBase64url } from "./util.ts"; +import { P256, decoder, encoder, fail, fromBase64url, randomBytes, toBase64url } from "./util.ts"; import type { Bytes } from "./util.ts"; import type { ContentKey, RecipientPrivateKey, RecipientPublicKey } from "./keys.ts"; @@ -16,7 +16,6 @@ const APP = /^[a-z0-9-]{1,40}$/; const ID = /^[A-Za-z0-9_-]{22}$/; const CHANNELS = new Set(["definition", "draft", "submission", "shared"]); const ALGORITHMS = new Set(["A256GCM", "ECDH-ES+A256GCM"]); -const P256 = { name: "ECDH", namedCurve: "P-256" } as const; const ECDH_INFO = encoder.encode("malverdesoftware-envelope/v1 ECDH-ES+A256GCM"); function additionalData(context: EnvelopeContext): Bytes { diff --git a/packages/sealed-envelope/src/keys.ts b/packages/sealed-envelope/src/keys.ts index efe4005..dc35cd8 100644 --- a/packages/sealed-envelope/src/keys.ts +++ b/packages/sealed-envelope/src/keys.ts @@ -1,11 +1,10 @@ -import { EnvelopeError, fail, fromBase64url, randomBytes, toBase64url, encoder } from "./util.ts"; +import { EnvelopeError, P256, fail, fromBase64url, randomBytes, toBase64url, encoder } from "./util.ts"; import type { Bytes } from "./util.ts"; export type ContentKey = CryptoKey & { readonly __brand: "ContentKey" }; export type RecipientPublicKey = CryptoKey & { readonly __brand: "RecipientPublicKey" }; export type RecipientPrivateKey = CryptoKey & { readonly __brand: "RecipientPrivateKey" }; -const P256 = { name: "ECDH", namedCurve: "P-256" } as const; const TOKEN = /^[A-Za-z0-9_-]{1,256}$/; export const randomSecret = (): string => toBase64url(randomBytes(32)); diff --git a/packages/sealed-envelope/src/util.ts b/packages/sealed-envelope/src/util.ts index 0fa3a19..41d4cc0 100644 --- a/packages/sealed-envelope/src/util.ts +++ b/packages/sealed-envelope/src/util.ts @@ -20,6 +20,8 @@ export const fail = (code: EnvelopeErrorCode, message: string): never => { throw new EnvelopeError(code, message); }; +export const P256 = { name: "ECDH", namedCurve: "P-256" } as const; + export const encoder = new TextEncoder(); export const decoder = new TextDecoder("utf-8", { fatal: true }); diff --git a/packages/session-client/src/file.ts b/packages/session-client/src/file.ts index 6d5aeba..78aa7ab 100644 --- a/packages/session-client/src/file.ts +++ b/packages/session-client/src/file.ts @@ -12,6 +12,7 @@ import { import type { Envelope } from "@malverdesoftware/sealed-envelope"; import { fail } from "./errors.ts"; import { buildLink, isAllowedUrl } from "./link.ts"; +import { APP, ID, SECRET } from "./patterns.ts"; import { isObject } from "./transport.ts"; export type ParticipantEntry = { @@ -54,10 +55,7 @@ export type PrepareInput = { export const FORMAT = "malverdesoftware-session/v1"; export const MAX_PARTICIPANTS = 200; const MARKER = ""; -const APP = /^[a-z0-9-]{1,40}$/; const LABEL = /^[A-Za-z0-9 _.-]{1,40}$/; -const ID = /^[A-Za-z0-9_-]{22}$/; -const SECRET = /^[A-Za-z0-9_-]{43}$/; const PUBLIC_KEY = /^[A-Za-z0-9_-]{87}$/; const BASE64URL = /^[A-Za-z0-9_-]+$/; const PASSCODE = /^[0-9]{6}$/; diff --git a/packages/session-client/src/link.ts b/packages/session-client/src/link.ts index 8ad0455..8198bd7 100644 --- a/packages/session-client/src/link.ts +++ b/packages/session-client/src/link.ts @@ -1,20 +1,20 @@ import { fail } from "./errors.ts"; +import { ID, SECRET } from "./patterns.ts"; export type ParticipantCredentials = { sessionId: string; definitionKey: string; slotSecret: string }; -const ID = /^[A-Za-z0-9_-]{22}$/; -const SECRET = /^[A-Za-z0-9_-]{43}$/; - // Everything secret sits after "#", which browsers never send to servers or in Referer headers. export const buildLink = (participantUrl: string, c: ParticipantCredentials) => `${participantUrl}#v1.${c.sessionId}.${c.definitionKey}.${c.slotSecret}`; +export const badLink = (): never => fail("BAD_LINK", "This link is incomplete. Open the full link you were sent."); + export function parseParticipantLink(url: string): ParticipantCredentials { const text = typeof url === "string" ? url.trim() : ""; const fragment = text.includes("#") ? text.slice(text.indexOf("#") + 1) : ""; const [version, sessionId = "", definitionKey = "", slotSecret = "", ...rest] = fragment.split("."); if (version !== "v1" || rest.length || !ID.test(sessionId) || !SECRET.test(definitionKey) || !SECRET.test(slotSecret)) - fail("BAD_LINK", "This link is incomplete. Open the full link you were sent."); + badLink(); return { sessionId, definitionKey, slotSecret }; } diff --git a/packages/session-client/src/participant.ts b/packages/session-client/src/participant.ts index 0361a7c..74dd983 100644 --- a/packages/session-client/src/participant.ts +++ b/packages/session-client/src/participant.ts @@ -10,8 +10,9 @@ import { } from "@malverdesoftware/sealed-envelope"; import type { Channel, Envelope } from "@malverdesoftware/sealed-envelope"; import { SessionError, fail } from "./errors.ts"; -import { isAllowedUrl } from "./link.ts"; +import { badLink, isAllowedUrl } from "./link.ts"; import type { ParticipantCredentials } from "./link.ts"; +import { APP } from "./patterns.ts"; import { defaultStorage, safeStorage } from "./storage.ts"; import type { KeyValueStorage } from "./storage.ts"; import { isEnvelope, isObject, shapes, transport } from "./transport.ts"; @@ -48,7 +49,6 @@ type LocalState = { submitted?: { body: unknown; submittedAt: string }; }; -const APP = /^[a-z0-9-]{1,40}$/; const sameJson = (a: unknown, b: unknown) => JSON.stringify(a) === JSON.stringify(b); const isCode = (error: unknown, code: string): error is SessionError => error instanceof SessionError && error.code === code; @@ -71,6 +71,12 @@ function readLocal(text: string | null): LocalState { } } +// A save the relay did not confirm stays on the device; the app shows why. +const savedOnDevice = (error: SessionError): SaveResult => ({ + state: "SAVED_ON_DEVICE", + reason: error.details?.reason === "SERVER_ERROR" ? "SERVER_ERROR" : "OFFLINE", +}); + const sealing = (error: unknown): never => { if (error instanceof EnvelopeError && error.code === "TOO_LARGE") fail("TOO_LARGE", "This response is too large to save. Remove some text and try again."); @@ -84,7 +90,7 @@ export async function joinSession(credentials: ParticipantCredentials, options: fail("BAD_INPUT", "The app name or relay address is not valid."); const { sessionId, definitionKey, slotSecret } = credentials; const storage = safeStorage(options.storage ?? defaultStorage()); - const prefix = `malverdesoftware:${app}:${sessionId}:${await hashToken(slotSecret).catch(() => fail("BAD_LINK", "This link is incomplete. Open the full link you were sent."))}`; + const prefix = `malverdesoftware:${app}:${sessionId}:${await hashToken(slotSecret).catch(badLink)}`; const passcodeKey = `${prefix}:passcode`; const stateKey = `${prefix}:state`; @@ -92,7 +98,7 @@ export async function joinSession(credentials: ParticipantCredentials, options: if (!passcode) fail("PASSCODE_REQUIRED", "Enter the six-digit passcode you were sent."); const slot = await deriveSlot(slotSecret, passcode!).catch((error: unknown): never => { if (error instanceof EnvelopeError && error.code === "BAD_FORMAT") fail("WRONG_PASSCODE", "A passcode is six digits."); - if (error instanceof EnvelopeError) fail("BAD_LINK", "This link is incomplete. Open the full link you were sent."); + if (error instanceof EnvelopeError) badLink(); throw error; }); const call = transport(relayUrl, options); @@ -110,7 +116,7 @@ export async function joinSession(credentials: ParticipantCredentials, options: }); const unreadable = (): never => fail("DECRYPT_FAILED", "This activity could not be opened. Check that you used the full link."); - const definitionKeyValue = await importContentKey(definitionKey).catch(() => fail("BAD_LINK", "This link is incomplete. Open the full link you were sent.")); + const definitionKeyValue = await importContentKey(definitionKey).catch(badLink); const payload = await open(definitionKeyValue, view.definition, { app, sessionId, channel: "definition" }).catch(unreadable); if (!isObject(payload) || payload.v !== 1 || typeof payload.researcherPublicKey !== "string" || !("appDefinition" in payload)) unreadable(); @@ -171,8 +177,7 @@ export async function joinSession(credentials: ParticipantCredentials, options: confirmDraft(saved.draftVersion); return { state: "SAVED" }; } catch (error) { - if (isCode(error, "NETWORK")) - return { state: "SAVED_ON_DEVICE", reason: error.details?.reason === "SERVER_ERROR" ? "SERVER_ERROR" : "OFFLINE" }; + if (isCode(error, "NETWORK")) return savedOnDevice(error); if (isCode(error, "VERSION_CONFLICT") && error.details?.draftVersion === state.baseVersion + 1) { // The outcome of an earlier attempt was unknown; if the stored draft is this one, it was saved. const latest = await call("GET", path, { token, check: shapes.view }); @@ -266,8 +271,7 @@ export async function joinSession(credentials: ParticipantCredentials, options: await sendSubmission(); return { state: "SAVED" }; } catch (error) { - if (isCode(error, "NETWORK")) - return { state: "SAVED_ON_DEVICE", reason: error.details?.reason === "SERVER_ERROR" ? "SERVER_ERROR" : "OFFLINE" }; + if (isCode(error, "NETWORK")) return savedOnDevice(error); throw error; } } diff --git a/packages/session-client/src/patterns.ts b/packages/session-client/src/patterns.ts new file mode 100644 index 0000000..a6e615d --- /dev/null +++ b/packages/session-client/src/patterns.ts @@ -0,0 +1,4 @@ +// Shapes of the identifiers and secrets this package reads. They mirror the relay's and the envelope's own checks. +export const APP = /^[a-z0-9-]{1,40}$/; +export const ID = /^[A-Za-z0-9_-]{22}$/; +export const SECRET = /^[A-Za-z0-9_-]{43}$/; diff --git a/packages/ui/src/controls.tsx b/packages/ui/src/controls.tsx index 0d6fd20..8b56454 100644 --- a/packages/ui/src/controls.tsx +++ b/packages/ui/src/controls.tsx @@ -30,6 +30,9 @@ export function ErrorMessage({ error }: { error: string }) { ) : null; } +/** For screens people tap: 44px buttons. */ +const touchSize = (touch: boolean | undefined) => (touch ? { height: ControlsHeight.L, className: "touch-button" } : {}); + /** A Ring button that opens a file picker. */ export function FileButton({ accept, icon, onPick, children }: { accept: string; @@ -73,7 +76,7 @@ export function ConfirmDialog({ show, title, confirmLabel, cancelLabel = "Cancel onCancel: () => void; children: ReactNode; }) { - const size = touch ? { height: ControlsHeight.L, className: "touch-button" } : {}; + const size = touchSize(touch); return (