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
+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() });
},
};
}