feat: F5.2 sign-in with the dashboard's ticket and the service way

Server F checks the dashboard's entry ticket as itsdangerous does
(tools/ticket-oracle: tickets made by itsdangerous 2.2.0 itself), redeems
it at /__auth for a session cookie (only the key's SHA-256 is kept) and
decides at /sync by the session's grant: none, read or write; a frozen
project is read by everyone; every refusal names its reason (one list in
packages/protocol/src/sync.ts). A WebSocket from a page of another origin
is refused. /svc/* with the service key makes, deletes, freezes and
thaws projects, ends a person's grant or all their sessions (connections
let go) and lists the sessions. Snapshots, restoring and copying move to
F6 (they need the canonical doc.json and an epoch).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 13:56:45 +00:00
co-authored by Claude Opus 5.5
parent 88db6eff7e
commit 5ded84cb33
24 changed files with 1794 additions and 151 deletions
+70
View File
@@ -0,0 +1,70 @@
// The exchange (plan §8.5): the dashboard's ticket in, F's cookie out. The
// editor's frame opens /__auth?ticket=…&next=/…; F checks the ticket,
// redeems it for a session (the browser's own, when it brings one) and
// sends the browser on to `next`, a place of F's own and nowhere else. Why
// a ticket is refused goes into the log, not into the answer.
import type { IncomingMessage, ServerResponse } from 'node:http';
import type { Log } from './log.js';
import { SESSION_SECONDS, type SessionStore } from './sessions.js';
import { cookieValue, SESSION_COOKIE } from './sync.js';
import { TicketError, verifyTicket } from './ticket.js';
export interface AuthOptions {
readonly sessions: SessionStore;
readonly log: Log;
readonly ticketSecret: string;
/** The cookie only over HTTPS; false on a developer's machine without it. */
readonly secureCookie: boolean;
}
const HERE = 'http://filius.invalid';
/**
* Where to go after the exchange: a path of F's own, else "/". Resolved as
* the browser resolves it, so that "//elsewhere", "/\elsewhere" (a
* backslash is a slash to the browser) and "/.//elsewhere" (which the
* resolving shortens to "//elsewhere") cannot lead away.
*/
export function safeNext(value: string | null): string {
if (value === null) return '/';
let url: URL;
try {
url = new URL(value, HERE);
} catch {
return '/';
}
if (url.origin !== HERE || url.pathname.startsWith('//')) return '/';
return `${url.pathname}${url.search}${url.hash}`;
}
export function sessionCookie(key: string, secure: boolean): string {
return `${SESSION_COOKIE}=${key}; Max-Age=${SESSION_SECONDS}; Path=/; HttpOnly; SameSite=Lax${secure ? '; Secure' : ''}`;
}
function answer(response: ServerResponse, status: number, body: object): void {
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
response.end(JSON.stringify(body));
}
export async function exchangeTicket(request: IncomingMessage, response: ServerResponse, { sessions, log, ticketSecret, secureCookie }: AuthOptions): Promise<void> {
const query = new URL(request.url ?? '/', HERE).searchParams;
let claims;
try {
claims = verifyTicket(query.get('ticket') ?? '', ticketSecret);
} catch (error) {
if (!(error instanceof TicketError)) throw error;
log.warn('auth.ticketRefused', { reason: error.message });
answer(response, 401, { error: 'invalidTicket' });
return;
}
const session = await sessions.redeem(claims, cookieValue(request.headers.cookie, SESSION_COOKIE));
if (session === undefined) {
log.warn('auth.unknownProject', { project: claims.project, user: claims.login });
answer(response, 404, { error: 'unknownProject' });
return;
}
log.info('auth.signedIn', { project: claims.project, user: claims.login, grant: claims.level });
response.writeHead(302, { location: safeNext(query.get('next')), 'set-cookie': sessionCookie(session.key, secureCookie), 'cache-control': 'no-store' });
response.end();
}
+6 -2
View File
@@ -1,7 +1,11 @@
// Server F (plan §8): the shared documents, and later the simulation rooms,
// the sign-in and the dashboard's service.
// Server F (plan §8): the shared documents, the sign-in and the dashboard's
// service; later the simulation rooms.
export * from './auth.js';
export * from './log.js';
export * from './persistence.js';
export * from './server.js';
export * from './service.js';
export * from './sessions.js';
export * from './store.js';
export * from './sync.js';
export * from './ticket.js';
+22 -13
View File
@@ -1,29 +1,38 @@
// Server F started from its environment: FILIUS_PORT (8080), DATABASE_URL.
// Until the sign-in comes (F5.2) every connection is refused, unless
// FILIUS_INSECURE_DEV=1 lets everyone write — on a developer's machine only.
// Server F started from its environment: FILIUS_PORT (8080), DATABASE_URL,
// FILIUS_TICKET_SECRET (shared with the dashboard, which signs the tickets),
// FILIUS_SERVICE_KEY (the dashboard's key to /svc/*), FILIUS_ORIGINS (the
// origins the editor is served from, separated by commas). Without one of
// them F does not start: an empty secret would let anyone sign. The cookie
// goes over HTTPS only, unless FILIUS_INSECURE_COOKIE=1 (a developer's
// machine without HTTPS).
import { jsonLog } from './log.js';
import { startServer } from './server.js';
import { PostgresSessions } from './sessions.js';
import { PostgresStore } from './store.js';
const log = jsonLog();
const url = process.env.DATABASE_URL;
if (url === undefined) {
log.error('config.missing', { variable: 'DATABASE_URL' });
const config = Object.fromEntries(['DATABASE_URL', 'FILIUS_TICKET_SECRET', 'FILIUS_SERVICE_KEY', 'FILIUS_ORIGINS'].map((name) => [name, process.env[name] ?? '']));
const missing = Object.keys(config).filter((name) => config[name] === '');
if (missing.length > 0) {
log.error('config.missing', { variables: missing });
process.exit(1);
}
const insecure = process.env.FILIUS_INSECURE_DEV === '1';
if (insecure) log.warn('auth.insecureDev', { note: 'every connection may write' });
const store = PostgresStore.connect(url);
const secureCookie = process.env.FILIUS_INSECURE_COOKIE !== '1';
if (!secureCookie) log.warn('auth.insecureCookie', { note: 'the session cookie goes over plain HTTP' });
const store = PostgresStore.connect(config.DATABASE_URL!);
const sessions = new PostgresSessions(store.pool);
await store.migrate();
await sessions.migrate();
const server = await startServer({
port: Number(process.env.FILIUS_PORT ?? 8080),
store,
sessions,
log,
authenticate: async () => {
if (insecure) return { readOnly: false };
throw new Error('no sign-in yet');
},
ticketSecret: config.FILIUS_TICKET_SECRET!,
serviceKey: config.FILIUS_SERVICE_KEY!,
origins: config.FILIUS_ORIGINS!.split(',').map((origin) => origin.trim()),
secureCookie,
});
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
process.once(signal, () => {
+10 -2
View File
@@ -4,7 +4,8 @@
// service asks (a minute after the last change, and when the last person
// leaves; plan §5.4). A compaction stores the document as it is and drops
// the updates stored before it; one stored after it stays, and applying it
// again on loading changes nothing (Yjs).
// again on loading changes nothing (Yjs). A project being deleted is
// discarded: what is still asked for is not stored any more.
import * as Y from 'yjs';
import { errorText, type Log } from './log.js';
@@ -21,6 +22,7 @@ export class DocumentPersistence {
private sinceCompaction = 0;
/** Updates queued since the last compaction was queued: what decides the next one. */
private queued = 0;
private discarded = false;
constructor(
private readonly store: DocStore,
@@ -47,7 +49,7 @@ export class DocumentPersistence {
}
private enqueue(work: () => Promise<void>): Promise<void> {
this.queue = this.queue.then(work).catch((error: unknown) => this.log.error('store.failed', { project: this.project, error: errorText(error) }));
this.queue = this.queue.then(() => (this.discarded ? undefined : work())).catch((error: unknown) => this.log.error('store.failed', { project: this.project, error: errorText(error) }));
return this.queue;
}
@@ -73,6 +75,12 @@ export class DocumentPersistence {
});
}
/** Nothing stored any more; done once what is being stored now is. */
discard(): Promise<void> {
this.discarded = true;
return this.flush();
}
/** Everything stored that was asked for. */
flush(): Promise<void> {
return this.enqueue(async () => {});
+46 -20
View File
@@ -1,19 +1,24 @@
// Server F's front door (plan §8.2): one HTTP server — /healthz for the
// proxy, the WebSocket /sync for the shared documents; anything else is not
// found. Closed, it first takes no one new (a client lets go and comes back
// at once, which would keep its document open), then lets everyone go,
// waits until every document is stored and unloaded, and stops.
// proxy, /__auth for the editor's frame (ticket in, cookie out), /svc/* for
// the dashboard (with the service key), the WebSocket /sync for the shared
// documents; anything else is not found. A WebSocket from a page of another
// origin is refused (a page elsewhere under the same domain would bring the
// person's cookie along: SameSite only tells sites apart). Closed, it first
// takes no one new (a client lets go and comes back at once, which would
// keep its document open), then lets everyone go, waits until every
// document is stored and unloaded, and stops.
import { createServer, type Server } from 'node:http';
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import type { AddressInfo, Socket } from 'node:net';
import type { Hocuspocus } from '@hocuspocus/server';
import { WebSocketServer } from 'ws';
import type { Log } from './log.js';
import { createSync, type SyncOptions } from './sync.js';
import { exchangeTicket } from './auth.js';
import { errorText, type Log } from './log.js';
import { handleService } from './service.js';
import { createSync, type Sync, type SyncOptions } from './sync.js';
export interface FiliusServer {
readonly port: number;
readonly sync: Hocuspocus;
readonly sync: Sync;
close(): Promise<void>;
}
@@ -21,25 +26,40 @@ export interface ServerOptions extends SyncOptions {
readonly port?: number;
readonly host?: string;
readonly log: Log;
readonly ticketSecret: string;
readonly serviceKey: string;
/** The origins the editor is served from (FILIUS_ORIGINS): a WebSocket from another is refused. */
readonly origins: readonly string[];
readonly secureCookie: boolean;
}
function refuse(socket: Socket, status: string): void {
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
}
function json(response: ServerResponse, status: number, value: object): void {
response.writeHead(status, { 'content-type': 'application/json' });
response.end(JSON.stringify(value));
}
export async function startServer(options: ServerOptions): Promise<FiliusServer> {
const sync = createSync(options);
const { hocuspocus } = sync;
const sockets = new WebSocketServer({ noServer: true });
let closing = false;
const handle = async (request: IncomingMessage, response: ServerResponse, path: string) => {
if (request.method === 'GET' && path === '/healthz') json(response, 200, { status: 'ok', documents: hocuspocus.getDocumentsCount(), connections: hocuspocus.getConnectionsCount() });
else if (request.method === 'GET' && path === '/__auth') await exchangeTicket(request, response, options);
else if (path.startsWith('/svc/')) await handleService(request, response, path, { ...options, sync });
else json(response, 404, { error: 'notFound' });
};
const http: Server = createServer((request, response) => {
const path = new URL(request.url ?? '/', 'http://filius').pathname;
if (request.method === 'GET' && path === '/healthz') {
response.writeHead(200, { 'content-type': 'application/json' });
response.end(JSON.stringify({ status: 'ok', documents: sync.getDocumentsCount(), connections: sync.getConnectionsCount() }));
return;
}
response.writeHead(404, { 'content-type': 'application/json' });
response.end(JSON.stringify({ error: 'notFound' }));
handle(request, response, path).catch((error: unknown) => {
options.log.error('http.failed', { path, error: errorText(error) });
if (!response.headersSent) json(response, 500, { error: 'internal' });
else response.end();
});
});
http.on('upgrade', (request, socket, head) => {
const path = new URL(request.url ?? '/', 'http://filius').pathname;
@@ -47,11 +67,17 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
refuse(socket as Socket, '404 Not Found');
return;
}
const origin = request.headers.origin;
if (origin !== undefined && !options.origins.includes(origin)) {
options.log.warn('sync.foreignOrigin', { origin });
refuse(socket as Socket, '403 Forbidden');
return;
}
if (closing) {
refuse(socket as Socket, '503 Service Unavailable');
return;
}
sockets.handleUpgrade(request, socket, head, (ws) => sync.handleConnection(ws, request));
sockets.handleUpgrade(request, socket, head, (ws) => hocuspocus.handleConnection(ws, request));
});
await new Promise<void>((resolve) => http.listen(options.port ?? 0, options.host ?? '0.0.0.0', resolve));
const port = (http.address() as AddressInfo).port;
@@ -61,14 +87,14 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
sync,
async close() {
closing = true;
sync.closeConnections();
hocuspocus.closeConnections();
// Hocuspocus' close is a message on the socket, after which a client signs in again on it: the sockets go too.
for (const socket of sockets.clients) socket.close(1001, 'service stopping');
// Each document is stored and unloaded once its last person has gone.
for (let waited = 0; sync.getDocumentsCount() > 0 && waited < 10_000; waited += 50) await new Promise((resolve) => setTimeout(resolve, 50));
for (let waited = 0; hocuspocus.getDocumentsCount() > 0 && waited < 10_000; waited += 50) await new Promise((resolve) => setTimeout(resolve, 50));
sockets.close();
await new Promise<void>((resolve) => http.close(() => resolve()));
options.log.info('server.closed', { documents: sync.getDocumentsCount() });
options.log.info('server.closed', { documents: hocuspocus.getDocumentsCount() });
},
};
}
+147
View File
@@ -0,0 +1,147 @@
// The dashboard's way into F (plan §8.5): /svc/*, with the service key and
// without a person — the dashboard has decided who may, F does it. Make a
// project (an empty FILIUS project), delete one (whoever is in it is let
// go), freeze or thaw one (whoever is in it reads from then on, or writes
// again with a write grant), end a person's grant to a project or all their
// sessions (whoever is connected is let go), and list who is signed in to
// what (the dashboard checks it against its shares). Snapshots, restoring,
// copying and the .fls come with the commits (F6).
import { createHash, timingSafeEqual } from 'node:crypto';
import type { IncomingMessage, ServerResponse } from 'node:http';
import * as Y from 'yjs';
import { emptyProject, writeProject } from '@mdg-filius/doc';
import type { SyncClosing } from '@mdg-filius/protocol';
import type { Log } from './log.js';
import type { SessionStore } from './sessions.js';
import type { DocStore } from './store.js';
import { accessMessage, type ConnectionContext, type Sync } from './sync.js';
import { isUuid } from './ticket.js';
export const SERVICE_KEY_HEADER = 'x-filius-service-key';
/** A request's body at most (the service sends small JSON). */
export const SERVICE_BODY_BYTES = 64 * 1024;
/** Hocuspocus' code for a connection ended for want of rights. */
const FORBIDDEN = 4403;
export interface ServiceOptions {
readonly store: DocStore;
readonly sessions: SessionStore;
readonly sync: Sync;
readonly log: Log;
readonly serviceKey: string;
}
class HttpError extends Error {
constructor(
readonly status: number,
readonly code: string,
) {
super(code);
}
}
const digest = (value: string) => createHash('sha256').update(value).digest();
/** The service key compared in constant time (the time an answer takes tells nothing of how much was right). */
export function serviceKeyMatches(given: string | string[] | undefined, expected: string): boolean {
return expected !== '' && typeof given === 'string' && timingSafeEqual(digest(given), digest(expected));
}
async function body(request: IncomingMessage): Promise<Record<string, unknown>> {
const parts: Buffer[] = [];
let size = 0;
for await (const part of request as AsyncIterable<Buffer>) {
size += part.length;
if (size > SERVICE_BODY_BYTES) throw new HttpError(413, 'tooLarge');
parts.push(part);
}
let value: unknown;
try {
value = JSON.parse(Buffer.concat(parts).toString('utf8'));
} catch {
throw new HttpError(400, 'notJson');
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new HttpError(400, 'notAnObject');
return value as Record<string, unknown>;
}
function uuid(value: unknown, code: string): string {
if (!isUuid(value)) throw new HttpError(400, code);
return value;
}
const contextOf = (connection: { context: unknown }) => connection.context as ConnectionContext;
export async function handleService(request: IncomingMessage, response: ServerResponse, path: string, options: ServiceOptions): Promise<void> {
const send = (status: number, value: object) => {
response.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' });
response.end(JSON.stringify(value));
};
if (!serviceKeyMatches(request.headers[SERVICE_KEY_HEADER], options.serviceKey)) {
options.log.warn('svc.forbidden', { path });
send(403, { error: 'forbidden' });
return;
}
try {
const [status, value] = await route(request, path, options);
send(status, value);
} catch (error) {
if (!(error instanceof HttpError)) throw error;
send(error.status, { error: error.code });
}
}
async function route(request: IncomingMessage, path: string, { store, sessions, sync, log }: ServiceOptions): Promise<[number, object]> {
const close = (connections: ReturnType<Sync['connections']>, reason: SyncClosing) => {
for (const connection of connections) connection.close({ code: FORBIDDEN, reason });
return connections.length;
};
const project = /^\/svc\/projects\/([^/]+)(\/freeze)?$/.exec(path);
if (request.method === 'POST' && path === '/svc/projects') {
const id = uuid((await body(request)).project, 'notAProject');
const doc = new Y.Doc();
writeProject(doc, emptyProject());
if (!(await store.create(id, Y.encodeStateAsUpdate(doc)))) throw new HttpError(409, 'exists');
log.info('svc.created', { project: id });
return [201, { project: id }];
}
if (request.method === 'DELETE' && project !== null && project[2] === undefined) {
const id = uuid(project[1], 'notAProject');
// Not stored any more, then gone (the grants with it), then everyone let go: coming back, they find no project.
await sync.discard(id);
if (!(await store.remove(id))) throw new HttpError(404, 'unknownProject');
const disconnected = close(sync.connections(id), 'deleted');
log.info('svc.deleted', { project: id, disconnected });
return [200, { deleted: true, disconnected }];
}
if (request.method === 'POST' && project !== null && project[2] === '/freeze') {
const id = uuid(project[1], 'notAProject');
const frozen = (await body(request)).frozen;
if (typeof frozen !== 'boolean') throw new HttpError(400, 'frozenNotABoolean');
if (!(await store.freeze(id, frozen))) throw new HttpError(404, 'unknownProject');
for (const connection of sync.connections(id)) {
const readOnly = frozen || contextOf(connection).level === 'read';
if (connection.readOnly === readOnly) continue;
connection.readOnly = readOnly;
connection.sendStateless(accessMessage(readOnly, frozen));
}
log.info('svc.frozen', { project: id, frozen });
return [200, { frozen }];
}
if (request.method === 'POST' && path === '/svc/revoke') {
const given = await body(request);
const user = uuid(given.user, 'notAPerson');
const only = given.project === undefined ? undefined : uuid(given.project, 'notAProject');
// The grant first: whoever is let go and comes back at once is refused.
const revoked = await sessions.revoke(user, only);
const disconnected = close(
sync.connections(only).filter((connection) => contextOf(connection).person.user === user),
'revoked',
);
log.info('svc.revoked', { user, ...(only === undefined ? {} : { project: only }), revoked, disconnected });
return [200, { revoked, disconnected }];
}
if (request.method === 'GET' && path === '/svc/sessions') return [200, await sessions.active()];
throw new HttpError(404, 'notFound');
}
+168
View File
@@ -0,0 +1,168 @@
// Who is signed in at F, and to which projects (plan §8.5). A ticket is
// exchanged for a session (the cookie holds its key; F keeps only the key's
// SHA-256, so that a read of the table signs no one in), and the session
// gets the ticket's grant: this project, this level. Opening a second
// project in the same browser adds a grant to the session; a ticket for
// someone else ends the session the browser had (a shared iPad: the next
// person is not the last one). A session lasts a school day; every ticket
// lengthens it. The dashboard ends grants and sessions (/svc/revoke) and
// asks which there are (/svc/sessions).
import { createHash, randomBytes } from 'node:crypto';
import type pg from 'pg';
import type { Level, TicketClaims } from './ticket.js';
export const SESSION_SECONDS = 12 * 60 * 60;
export interface SessionPerson {
readonly user: string;
readonly login: string;
readonly name: string;
readonly color: string;
readonly language: string;
}
/** What a session may do in a project. */
export type ProjectAccess =
| { readonly kind: 'notSignedIn' }
| { readonly kind: 'unknownProject'; readonly person: SessionPerson }
| { readonly kind: 'noAccess'; readonly person: SessionPerson }
| { readonly kind: 'granted'; readonly person: SessionPerson; readonly level: Level; readonly frozen: boolean };
export interface ActiveSessions {
readonly users: readonly string[];
readonly grants: readonly { readonly user: string; readonly project: string; readonly level: Level }[];
}
export interface SessionStore {
migrate(): Promise<void>;
/** A ticket redeemed: the browser's session (its key, when it brought one) or a new one; undefined if F does not know the project. */
redeem(claims: TicketClaims, key?: string): Promise<{ readonly key: string } | undefined>;
access(key: string, project: string): Promise<ProjectAccess>;
/** A person's grant to a project ended, or (without a project) all their sessions; how many. */
revoke(user: string, project?: string): Promise<number>;
active(): Promise<ActiveSessions>;
}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS filius_session (
key_hash text PRIMARY KEY,
user_uuid uuid NOT NULL,
login text NOT NULL,
display_name text NOT NULL,
color text NOT NULL,
language text NOT NULL,
expires_at timestamptz NOT NULL
);
CREATE INDEX IF NOT EXISTS filius_session_user ON filius_session (user_uuid);
CREATE TABLE IF NOT EXISTS filius_grant (
key_hash text NOT NULL REFERENCES filius_session ON DELETE CASCADE,
project_uuid uuid NOT NULL REFERENCES filius_doc ON DELETE CASCADE,
level text NOT NULL CHECK (level IN ('read', 'write')),
PRIMARY KEY (key_hash, project_uuid)
);
`;
const hashOf = (key: string) => createHash('sha256').update(key).digest('hex');
interface PersonRow {
readonly user_uuid: string;
readonly login: string;
readonly display_name: string;
readonly color: string;
readonly language: string;
}
const person = (row: PersonRow): SessionPerson => ({ user: row.user_uuid, login: row.login, name: row.display_name, color: row.color, language: row.language });
export class PostgresSessions implements SessionStore {
constructor(private readonly pool: pg.Pool) {}
async migrate(): Promise<void> {
await this.pool.query(SCHEMA);
}
async redeem(claims: TicketClaims, key?: string): Promise<{ readonly key: string } | undefined> {
const client = await this.pool.connect();
try {
await client.query('BEGIN');
await client.query('DELETE FROM filius_session WHERE expires_at <= now()');
const project = await client.query('SELECT 1 FROM filius_doc WHERE project_uuid = $1 FOR SHARE', [claims.project]);
if (project.rowCount !== 1) {
await client.query('ROLLBACK');
return undefined;
}
const values = [claims.user, claims.login, claims.name, claims.color, claims.language, SESSION_SECONDS];
let kept = key === undefined ? undefined : hashOf(key);
if (kept !== undefined) {
const same = await client.query(
`UPDATE filius_session SET login = $3, display_name = $4, color = $5, language = $6, expires_at = now() + make_interval(secs => $7)
WHERE key_hash = $1 AND user_uuid = $2`,
[kept, ...values],
);
if (same.rowCount !== 1) {
await client.query('DELETE FROM filius_session WHERE key_hash = $1', [kept]);
kept = undefined;
}
}
let fresh = key;
if (kept === undefined) {
fresh = randomBytes(32).toString('base64url');
kept = hashOf(fresh);
await client.query(
`INSERT INTO filius_session (key_hash, user_uuid, login, display_name, color, language, expires_at)
VALUES ($1, $2, $3, $4, $5, $6, now() + make_interval(secs => $7))`,
[kept, ...values],
);
}
await client.query('INSERT INTO filius_grant (key_hash, project_uuid, level) VALUES ($1, $2, $3) ON CONFLICT (key_hash, project_uuid) DO UPDATE SET level = EXCLUDED.level', [
kept,
claims.project,
claims.level,
]);
await client.query('COMMIT');
return { key: fresh! };
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async access(key: string, project: string): Promise<ProjectAccess> {
const result = await this.pool.query<PersonRow & { readonly known: boolean; readonly level: Level | null; readonly frozen: boolean | null }>(
`SELECT s.user_uuid, s.login, s.display_name, s.color, s.language, d.project_uuid IS NOT NULL AS known, g.level, d.frozen
FROM filius_session s
LEFT JOIN filius_doc d ON d.project_uuid = $2
LEFT JOIN filius_grant g ON g.key_hash = s.key_hash AND g.project_uuid = $2
WHERE s.key_hash = $1 AND s.expires_at > now()`,
[hashOf(key), project],
);
const row = result.rows[0];
if (row === undefined) return { kind: 'notSignedIn' };
if (!row.known) return { kind: 'unknownProject', person: person(row) };
if (row.level === null) return { kind: 'noAccess', person: person(row) };
return { kind: 'granted', person: person(row), level: row.level, frozen: row.frozen === true };
}
async revoke(user: string, project?: string): Promise<number> {
const result =
project === undefined
? await this.pool.query('DELETE FROM filius_session WHERE user_uuid = $1', [user])
: await this.pool.query('DELETE FROM filius_grant g USING filius_session s WHERE g.key_hash = s.key_hash AND s.user_uuid = $1 AND g.project_uuid = $2', [user, project]);
return result.rowCount ?? 0;
}
async active(): Promise<ActiveSessions> {
const users = await this.pool.query<{ user_uuid: string }>('SELECT DISTINCT user_uuid FROM filius_session WHERE expires_at > now() ORDER BY user_uuid');
// Two devices, the same grant twice: the stronger level counts (the one the dashboard has to check); 'write' sorts after 'read'.
const grants = await this.pool.query<{ user_uuid: string; project_uuid: string; level: Level }>(
`SELECT s.user_uuid, g.project_uuid, max(g.level) AS level
FROM filius_grant g JOIN filius_session s ON s.key_hash = g.key_hash
WHERE s.expires_at > now()
GROUP BY s.user_uuid, g.project_uuid ORDER BY s.user_uuid, g.project_uuid`,
);
return { users: users.rows.map((row) => row.user_uuid), grants: grants.rows.map((row) => ({ user: row.user_uuid, project: row.project_uuid, level: row.level })) };
}
}
+16 -5
View File
@@ -2,7 +2,8 @@
// filius_doc, the updates since then in filius_doc_update, in the order they
// came. A project's row is written when the project is made, so that the
// compaction's FOR UPDATE has a row to lock (a lock on a missing row locks
// nothing; Scratch's lesson 6.13).
// nothing; Scratch's lesson 6.13). A frozen project (its deadline passed,
// plan §10.3) is read by everyone and written by no one.
import pg from 'pg';
@@ -25,9 +26,12 @@ export interface DocStore {
append(project: string, update: Uint8Array): Promise<string>;
/** A new state, which holds every update up to `upToSeq`: those go. */
compact(project: string, state: Uint8Array, upToSeq: string): Promise<void>;
/** A project frozen or thawed; false without the project. */
freeze(project: string, frozen: boolean): Promise<boolean>;
/** How many updates wait for the next compaction. */
pending(project: string): Promise<number>;
remove(project: string): Promise<void>;
/** A project gone, its updates with it; false without the project. */
remove(project: string): Promise<boolean>;
close(): Promise<void>;
}
@@ -44,12 +48,13 @@ CREATE TABLE IF NOT EXISTS filius_doc_update (
update bytea NOT NULL,
PRIMARY KEY (project_uuid, seq)
);
ALTER TABLE filius_doc ADD COLUMN IF NOT EXISTS frozen boolean NOT NULL DEFAULT false;
`;
const bytes = (buffer: Buffer) => new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
export class PostgresStore implements DocStore {
constructor(private readonly pool: pg.Pool) {}
constructor(readonly pool: pg.Pool) {}
static connect(url: string): PostgresStore {
return new PostgresStore(new pg.Pool({ connectionString: url }));
@@ -94,13 +99,19 @@ export class PostgresStore implements DocStore {
}
}
async freeze(project: string, frozen: boolean): Promise<boolean> {
const result = await this.pool.query('UPDATE filius_doc SET frozen = $2 WHERE project_uuid = $1', [project, frozen]);
return result.rowCount === 1;
}
async pending(project: string): Promise<number> {
const result = await this.pool.query<{ count: string }>('SELECT count(*) FROM filius_doc_update WHERE project_uuid = $1', [project]);
return Number(result.rows[0]!.count);
}
async remove(project: string): Promise<void> {
await this.pool.query('DELETE FROM filius_doc WHERE project_uuid = $1', [project]);
async remove(project: string): Promise<boolean> {
const result = await this.pool.query('DELETE FROM filius_doc WHERE project_uuid = $1', [project]);
return result.rowCount === 1;
}
async close(): Promise<void> {
+60 -20
View File
@@ -1,19 +1,24 @@
// The shared documents (/sync, plan §5): Hocuspocus, a project a document.
// A project is loaded from the store when its first person comes (a project
// the store has not got is refused, with its reason: projects are made
// through the service, F5.2), every
// Who comes in is decided by the session the browser's cookie names (plan
// §8.5): its grant to the project, read or write; a frozen project is read
// by everyone; a refusal says why (SYNC_REFUSALS). A project is loaded from
// the store when its first person comes (projects are made through the
// service), every
// update stored as it comes, compacted a minute after the last change, when
// the last person leaves and after COMPACT_AFTER_UPDATES updates. The
// janitor (plan §5.3) runs only here, as a client of its own: once when the
// document is loaded, then 250 ms after the last update that was not its
// own; its repair goes to everyone like any update.
import { Hocuspocus, type onAuthenticatePayload } from '@hocuspocus/server';
import { Hocuspocus, type Connection } from '@hocuspocus/server';
import type * as Y from 'yjs';
import { JANITOR_ORIGIN, repair } from '@mdg-filius/doc';
import type { AccessMessage, SyncRefusal } from '@mdg-filius/protocol';
import { errorText, type Log } from './log.js';
import { DocumentPersistence } from './persistence.js';
import type { SessionPerson, SessionStore } from './sessions.js';
import type { DocStore } from './store.js';
import { isUuid, type Level } from './ticket.js';
/** How long after the last change a document is compacted (Hocuspocus' debounce of onStoreDocument). */
export const COMPACT_AFTER_IDLE_MILLISECONDS = 60_000;
@@ -22,27 +27,49 @@ export const COMPACT_AT_LATEST_MILLISECONDS = 600_000;
/** The janitor's wait after an update (entprellt). */
export const JANITOR_DELAY_MILLISECONDS = 250;
/** What a connection may do; thrown, it is refused. */
export interface Access {
readonly readOnly: boolean;
/** Who, for the log. */
readonly user?: string;
/** The cookie that names a browser's session at F. */
export const SESSION_COOKIE = 'filius_session';
/** Who is behind a connection, and with which grant (Hocuspocus' context of the connection). */
export interface ConnectionContext {
readonly person: SessionPerson;
readonly level: Level;
}
export interface SyncOptions {
readonly store: DocStore;
readonly sessions: SessionStore;
readonly log: Log;
readonly authenticate: (payload: onAuthenticatePayload) => Promise<Access>;
readonly compactAfterIdleMilliseconds?: number;
}
export interface Sync {
readonly hocuspocus: Hocuspocus;
/** The connections to a project, or to every project. */
connections(project?: string): Connection[];
/** A project's document not stored any more, while it is open (it is being deleted). */
discard(project: string): Promise<void>;
}
/** A refusal of a connection, with the reason the person's editor is told (Hocuspocus sends an error's `reason`). */
export function refusal(reason: string): Error & { readonly reason: string } {
export function refusal(reason: SyncRefusal): Error & { readonly reason: SyncRefusal } {
return Object.assign(new Error(reason), { reason });
}
/** A project's id: a UUID, as the dashboard makes them. */
export const isProjectId = (name: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(name);
/** A cookie's value from a Cookie header. */
export function cookieValue(header: string | undefined, name: string): string | undefined {
for (const part of (header ?? '').split(';')) {
const cut = part.indexOf('=');
if (cut > 0 && part.slice(0, cut).trim() === name) return part.slice(cut + 1).trim();
}
return undefined;
}
/** What a connection is told when its access changes. */
export function accessMessage(readOnly: boolean, frozen: boolean): string {
const message: AccessMessage = { v: 1, kind: 'access', readOnly, frozen };
return JSON.stringify(message);
}
/** The janitor on a document; the function that ends it. */
function startJanitor(doc: Y.Doc, project: string, log: Log): () => void {
@@ -66,17 +93,23 @@ function startJanitor(doc: Y.Doc, project: string, log: Log): () => void {
};
}
export function createSync({ store, log, authenticate, compactAfterIdleMilliseconds = COMPACT_AFTER_IDLE_MILLISECONDS }: SyncOptions): Hocuspocus {
export function createSync({ store, sessions, log, compactAfterIdleMilliseconds = COMPACT_AFTER_IDLE_MILLISECONDS }: SyncOptions): Sync {
const open = new Map<string, { readonly persistence: DocumentPersistence; stopJanitor?: () => void }>();
return new Hocuspocus({
const hocuspocus = new Hocuspocus({
quiet: true,
debounce: compactAfterIdleMilliseconds,
maxDebounce: COMPACT_AT_LATEST_MILLISECONDS,
async onAuthenticate(payload) {
if (!isProjectId(payload.documentName)) throw refusal('notAProject');
const access = await authenticate(payload);
payload.connectionConfig.readOnly = access.readOnly;
return access;
async onAuthenticate({ documentName: project, requestHeaders, connectionConfig }): Promise<ConnectionContext> {
if (!isUuid(project)) throw refusal('notAProject');
const key = cookieValue(requestHeaders.cookie, SESSION_COOKIE);
const access = key === undefined ? ({ kind: 'notSignedIn' } as const) : await sessions.access(key, project);
if (access.kind !== 'granted') {
log.info('sync.refused', { project, reason: access.kind, ...(access.kind === 'notSignedIn' ? {} : { user: access.person.login }) });
throw refusal(access.kind);
}
connectionConfig.readOnly = access.level === 'read' || access.frozen;
log.info('sync.joined', { project, user: access.person.login, readOnly: connectionConfig.readOnly });
return { person: access.person, level: access.level };
},
async onLoadDocument({ documentName, document }) {
const persistence = new DocumentPersistence(store, documentName, log);
@@ -110,4 +143,11 @@ export function createSync({ store, log, authenticate, compactAfterIdleMilliseco
log.info('doc.unloaded', { project: documentName });
},
});
return {
hocuspocus,
connections: (project) => (project === undefined ? [...hocuspocus.documents.values()] : [hocuspocus.documents.get(project)].filter((doc) => doc !== undefined)).flatMap((doc) => doc.getConnections()),
discard: async (project) => {
await open.get(project)?.persistence.discard();
},
};
}
+97
View File
@@ -0,0 +1,97 @@
// The dashboard's entry ticket (plan §8.5), checked: itsdangerous'
// URLSafeTimedSerializer as the dashboard signs it — the payload as compact
// JSON, zlib-packed when that is shorter (then a leading "."), base64url;
// the moment of signing as the shortest big-endian bytes, base64url; an
// HMAC-SHA1 over both under the key sha1(salt + "signer" + secret)
// ("django-concat"), base64url. Valid for TICKET_SECONDS after signing and
// not before it, as itsdangerous counts (whole seconds). What lies under a
// matching signature the dashboard made: its base64 is read as Node reads
// it (leniently, as Python does). Python's own tickets are the oracle
// (tools/ticket-oracle).
//
// What the ticket says: who (`u`, the login `n`, the name `d`, the colour
// `c`, the language `lang`), which project (`p`) and what for (`lvl`, read
// or write). The keys are a contract with the dashboard.
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
import { inflateSync } from 'node:zlib';
export const TICKET_SECONDS = 60;
export const TICKET_SALT = 'mdg-filius-ticket';
export const LEVELS = ['read', 'write'] as const;
export type Level = (typeof LEVELS)[number];
export interface TicketClaims {
readonly user: string;
readonly login: string;
readonly name: string;
readonly color: string;
readonly language: string;
readonly project: string;
readonly level: Level;
}
/** Why a ticket is no good: for the log, never for the answer. */
export class TicketError extends Error {}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
/** A UUID, as the dashboard makes them (lower case). */
export const isUuid = (value: unknown): value is string => typeof value === 'string' && UUID.test(value);
/** The signing key itsdangerous derives (django-concat over SHA-1). */
function signingKey(secret: string, salt: string): Buffer {
return createHash('sha1').update(salt).update('signer').update(secret).digest();
}
export function signature(value: string, secret: string, salt = TICKET_SALT): string {
return createHmac('sha1', signingKey(secret, salt)).update(value).digest('base64url');
}
/** The payload a ticket carries, signature and age checked; `now` in seconds since 1970. */
export function readSigned(ticket: string, secret: string, now: number, salt = TICKET_SALT): unknown {
if (secret === '') throw new TicketError('no secret');
const cut = ticket.lastIndexOf('.');
if (cut < 0) throw new TicketError('no signature');
const value = ticket.slice(0, cut);
const expected = Buffer.from(signature(value, secret, salt));
const given = Buffer.from(ticket.slice(cut + 1));
if (given.length !== expected.length || !timingSafeEqual(given, expected)) throw new TicketError('signature does not match');
const stamp = value.lastIndexOf('.');
if (stamp < 0) throw new TicketError('no timestamp');
const age = now - Buffer.from(value.slice(stamp + 1), 'base64url').reduce((sum, byte) => sum * 256 + byte, 0);
if (age > TICKET_SECONDS) throw new TicketError(`ticket ${age} s old`);
if (age < 0) throw new TicketError('ticket from the future');
const payload = value.slice(0, stamp);
const packed = payload.startsWith('.');
try {
const bytes = Buffer.from(packed ? payload.slice(1) : payload, 'base64url');
return JSON.parse((packed ? inflateSync(bytes) : bytes).toString('utf8')) as unknown;
} catch {
throw new TicketError('payload unreadable');
}
}
const text = (value: unknown): string | undefined => (typeof value === 'string' && value !== '' ? value : undefined);
/** A ticket's claims, or a TicketError. */
export function verifyTicket(ticket: string, secret: string, now = Math.floor(Date.now() / 1000)): TicketClaims {
const payload = readSigned(ticket, secret, now);
if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) throw new TicketError('payload is not an object');
const fields = payload as Record<string, unknown>;
if (!isUuid(fields.u)) throw new TicketError('no person');
const login = text(fields.n);
if (login === undefined) throw new TicketError('no login');
if (!isUuid(fields.p)) throw new TicketError('no project');
// Without a level nothing is granted: read is not the fallback, nothing is.
if (!LEVELS.includes(fields.lvl as Level)) throw new TicketError('no level');
return {
user: fields.u,
login,
name: text(fields.d) ?? login,
color: text(fields.c) ?? '',
language: text(fields.lang) ?? 'de',
project: fields.p,
level: fields.lvl as Level,
};
}