dorfteich/packages/shared/src/collab-token.ts
Claude Fable 5 3d1f4fda53
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
#188: purpose-bound token keys via HKDF, jose replaces the homegrown JWT
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
2026-07-30 06:41:11 +02:00

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';
};