dorfteich/packages/shared/src/collab-token.ts
Claude Fable 5 67fb01fe2b
All checks were successful
CI / Lint, typecheck, test (push) Successful in 3m26s
CI / Build container images (push) Has been skipped
CD / Build and push images (push) Successful in 3m49s
CD / Deploy to Test (push) Successful in 9s
CD / Smoke tests against Test (push) Successful in 1m12s
CD / Promote to Int (push) Successful in 10s
CI / Auth e2e pack (push) Successful in 5m27s
CI / Import/export fidelity gate (push) Successful in 46s
Notify watchers about page changes and comments, with an in-app center (#94)
New notifications table (payload denormalized for join-free rendering;
mailed_at already prepares the #95 digests). Generation fans page events
out to page and pond watchers, excluding the actors, and re-checks page
read permission per watcher at delivery time — a revoked watcher gets
nothing. Sources: named version snapshots (api), new comments (api), and
the collab server's automatic session-close snapshots — announced over a
new pg NOTIFY channel (the reverse of the established api→collab bus)
consumed by a dedicated LISTEN client in the api, since the collab server
has no permission resolution of its own. API: paginated list (unread
first via nulls-first ordering), mark read, mark all read. UI: bell with
unread badge in the top bar (30 s polling, no push in v1) and a dropdown
whose entries navigate and mark themselves read; comment notifications
deep-link with ?comments=1, which now opens the comments panel on load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EwZ4jR4KFAPvpjWevfUGX1
2026-07-11 23:20:25 +02:00

81 lines
3.2 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` (Node `crypto`) 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[];
}
/** 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';
};