All checks were successful
CI / Build container images (pull_request) Successful in 3m51s
CI / Auth e2e pack (pull_request) Successful in 7m49s
CI / Import/export fidelity gate (pull_request) Successful in 56s
CI / Lint, typecheck, test (pull_request) Successful in 4m43s
CD / Build and push images (push) Successful in 20s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m19s
CD / Promote to Int (push) Successful in 11s
CI / Lint, typecheck, test (push) Successful in 4m54s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 7m39s
CI / Import/export fidelity gate (push) Successful in 59s
COLLAB_TOKEN_SECRET becomes a root key: every purpose derives its own HKDF-SHA-256 subkey (deriveTokenKey), and no code path signs with the root key directly. Collaboration tokens are signed and verified by jose with HS256 as an explicit allowlist; the sign/verify API turns async at its three call sites. Unsubscribe tokens move from a purpose-prefix string to the structural subkey, with a documented dual-verify window (legacy derivation accepted until 2026-11-01, covering the 90-day TTL of links in already-sent mail). The cross-runtime property that justified the homegrown implementation is now proven by a test: the built CJS and ESM dist artefacts round-trip tokens in both directions in child processes (jose v6 reaches CJS via Node's require(esm), pinned Node 22 images). Negative tests cover cross-purpose subkeys, root-key-signed tokens, alg:none and RS256. Refs #188 (ADR 0020) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0168Ph5uBmHm8X28CSVpbpnJ
115 lines
4.5 KiB
TypeScript
115 lines
4.5 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
/**
|
|
* Types and schemas for the short-lived collaboration tokens (issue #34,
|
|
* ADR 0003/0007). These are browser-safe (no Node built-ins) so the web app
|
|
* can import them from the package barrel. The signing/verifying helpers live
|
|
* in `./token-crypto` (HKDF subkeys + `jose`, ADR 0020) and are imported only
|
|
* by the api and the collab server.
|
|
*/
|
|
|
|
export const collabTokenModeSchema = z.enum(['rw', 'ro']);
|
|
export type CollabTokenMode = z.infer<typeof collabTokenModeSchema>;
|
|
|
|
/**
|
|
* PostgreSQL `LISTEN/NOTIFY` channel over which the api announces that the
|
|
* access situation of a pond changed (issue #39, permissions.md §Performance).
|
|
* The notification payload is the pond id. The api emits it whenever a
|
|
* permission-relevant change happens (interim: pond soft-delete; from M5 on:
|
|
* grant changes, #53); the collab server listens and re-validates every open
|
|
* connection to that pond's pages by closing them so clients reconnect and
|
|
* re-acquire a token reflecting the current access.
|
|
*/
|
|
export const POND_ACCESS_CHANGED_CHANNEL = 'pond_access_changed';
|
|
|
|
/**
|
|
* PostgreSQL `NOTIFY` channel over which the api asks the collab server to
|
|
* restore a page to an earlier version (issue #42, ADR 0013). The api does the
|
|
* permission check, then emits this; the collab server owns the live document,
|
|
* so it snapshots the current state (a `PRE_RESTORE` version) and applies the
|
|
* restored content as a normal edit through the document, converging every open
|
|
* client. Payload is a JSON {@link PageRestoreRequest}.
|
|
*/
|
|
export const PAGE_RESTORE_CHANNEL = 'page_restore';
|
|
|
|
/**
|
|
* PostgreSQL `NOTIFY` channel over which the collab server announces that it
|
|
* wrote an automatic version snapshot (issue #94): the api listens and fans
|
|
* the change out to watchers as notifications — permission-checked there,
|
|
* where the resolution lives. Payload is a JSON {@link PageVersionCreatedEvent}.
|
|
*/
|
|
export const PAGE_VERSION_CREATED_CHANNEL = 'page_version_created';
|
|
|
|
/** JSON payload carried on {@link PAGE_VERSION_CREATED_CHANNEL}. */
|
|
export interface PageVersionCreatedEvent {
|
|
pageId: string;
|
|
/** Everyone who contributed to the snapshot — all excluded from fan-out. */
|
|
contributorIds: string[];
|
|
}
|
|
|
|
/**
|
|
* PostgreSQL `NOTIFY` channel over which the api asks the collab server to
|
|
* toggle a single task-list checkbox (issue #153). The api has already
|
|
* checked write permission; the collab server owns the live document and
|
|
* applies the attribute change as a normal edit, so every open client
|
|
* converges. Payload is a JSON {@link TaskToggleRequest}.
|
|
*/
|
|
export const TASK_TOGGLE_CHANNEL = 'task_toggle';
|
|
|
|
/**
|
|
* PostgreSQL `NOTIFY` channel over which the collab server announces that a
|
|
* persist added new user mentions to a page (issue #151). The api listens
|
|
* and creates the `mentioned` notifications — permission-checked there.
|
|
* Payload is a JSON {@link PageMentionsChangedEvent}.
|
|
*/
|
|
export const PAGE_MENTIONS_CHANGED_CHANNEL = 'page_mentions_changed';
|
|
|
|
/** JSON payload carried on {@link PAGE_MENTIONS_CHANGED_CHANNEL}. */
|
|
export interface PageMentionsChangedEvent {
|
|
pageId: string;
|
|
/** Users newly mentioned by this persist (diff against the stored rows). */
|
|
addedUserIds: string[];
|
|
}
|
|
|
|
/** JSON payload carried on {@link TASK_TOGGLE_CHANNEL}. */
|
|
export interface TaskToggleRequest {
|
|
pageId: string;
|
|
/** The task item's stable `id` attribute (issue #153). */
|
|
taskId: string;
|
|
checked: boolean;
|
|
/** The user who toggled — recorded as a pending contributor. */
|
|
userId: string;
|
|
}
|
|
|
|
/** JSON payload carried on {@link PAGE_RESTORE_CHANNEL}. */
|
|
export interface PageRestoreRequest {
|
|
pageId: string;
|
|
versionId: string;
|
|
/** The user who triggered the restore (recorded on the pre-restore snapshot). */
|
|
userId: string;
|
|
}
|
|
|
|
/** The application claims carried by a collaboration token. */
|
|
export const collabTokenClaimsSchema = z.object({
|
|
// `null` for an anonymous visitor holding a public read-only token (issue
|
|
// #53); a user id for a signed-in participant (used for presence/versioning).
|
|
userId: z.string().min(1).nullable(),
|
|
pageId: z.string().min(1),
|
|
mode: collabTokenModeSchema,
|
|
});
|
|
export type CollabTokenClaims = z.infer<typeof collabTokenClaimsSchema>;
|
|
|
|
/** Response of `GET /pages/:id/collab-token`. */
|
|
export interface CollabTokenResponse {
|
|
token: string;
|
|
mode: CollabTokenMode;
|
|
expiresInSeconds: number;
|
|
}
|
|
|
|
export type CollabTokenVerification =
|
|
| { valid: true; claims: CollabTokenClaims }
|
|
| {
|
|
valid: false;
|
|
reason: 'malformed' | 'bad_algorithm' | 'bad_signature' | 'expired' | 'invalid_claims';
|
|
};
|