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
+167
View File
@@ -0,0 +1,167 @@
// What the server's tests share: the log kept in memory, a server F with
// test secrets, a project as the service makes it, a person signed in with
// a ticket as the dashboard signs one, and that person in a project with
// Hocuspocus' own provider over a real WebSocket (bringing the session's
// cookie, as the browser does).
import { randomUUID } from 'node:crypto';
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
import { expect } from 'vitest';
import WebSocket from 'ws';
import * as Y from 'yjs';
import { emptyProject, writeProject } from '@mdg-filius/doc';
import { startServer, type DocStore, type FiliusServer, type Log, type ServerOptions, type SessionStore } from '../src/index.js';
import { signTicket } from './tickets.js';
export const TICKET_SECRET = 'test-ticket-secret';
export const SERVICE_KEY = 'test-service-key';
export const EDITOR_ORIGIN = 'http://editor.test';
export type MemoryLog = Log & { readonly lines: Record<string, unknown>[] };
/** The log's lines, kept. */
export function memoryLog(): MemoryLog {
const lines: Record<string, unknown>[] = [];
const entry =
(level: string) =>
(event: string, fields: Record<string, unknown> = {}) => {
lines.push(JSON.parse(JSON.stringify({ level, event, ...fields })) as Record<string, unknown>);
};
return { lines, info: entry('info'), warn: entry('warn'), error: entry('error') };
}
export type TestServer = FiliusServer & { readonly log: MemoryLog; readonly base: string };
export async function startTestServer(store: DocStore, sessions: SessionStore, options: Partial<ServerOptions> = {}): Promise<TestServer> {
const log = memoryLog();
const server = await startServer({
port: 0,
host: '127.0.0.1',
store,
sessions,
log,
ticketSecret: TICKET_SECRET,
serviceKey: SERVICE_KEY,
origins: [EDITOR_ORIGIN],
secureCookie: false,
...options,
});
return Object.assign(server, { log, base: `http://127.0.0.1:${server.port}` });
}
/** A new project as the service makes it: an empty FILIUS project. */
export async function newProject(store: DocStore): Promise<string> {
const project = randomUUID();
const doc = new Y.Doc();
writeProject(doc, emptyProject());
expect(await store.create(project, Y.encodeStateAsUpdate(doc))).toBe(true);
return project;
}
export interface Person {
readonly user?: string;
readonly login?: string;
readonly level?: 'read' | 'write';
/** The cookie the browser brings to /__auth. */
readonly cookie?: string;
}
/** The answer of /__auth to a fresh ticket. */
export function exchange(server: TestServer, project: string, { user = randomUUID(), login = 'ab12', level = 'write', cookie }: Person = {}, next = '/'): Promise<Response> {
const ticket = signTicket({ u: user, n: login, d: 'Ada Beispiel', c: '#3366cc', lang: 'de', p: project, lvl: level }, TICKET_SECRET);
return fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}&next=${encodeURIComponent(next)}`, {
redirect: 'manual',
...(cookie === undefined ? {} : { headers: { cookie } }),
});
}
/** Signed in through /__auth: the cookie for the browser's next requests. */
export async function signIn(server: TestServer, project: string, person: Person = {}): Promise<string> {
const response = await exchange(server, project, person);
expect(response.status).toBe(302);
return response.headers.get('set-cookie')!.split(';')[0]!;
}
export interface Client {
readonly doc: Y.Doc;
readonly provider: HocuspocusProvider;
/** Why F closed the connection, in order. */
readonly closings: string[];
/** Why F refused it after it had been in, in order. */
readonly refusals: string[];
/** Stateless messages from F. */
readonly messages: unknown[];
close(): void;
}
export interface JoinOptions {
readonly cookie?: string;
/** Comes back at once when let go (no delay, as a client may). */
readonly eager?: boolean;
readonly origin?: string;
}
/** A person in a project: synced, or refused. */
export function join(server: FiliusServer, project: string, { cookie, eager = false, origin }: JoinOptions = {}): Promise<Client> {
const doc = new Y.Doc();
const headers = { ...(cookie === undefined ? {} : { cookie }), ...(origin === undefined ? {} : { origin }) };
class Browser extends WebSocket {
constructor(url: string, protocols?: string | string[]) {
super(url, protocols, { headers });
// Let go while it is still connecting (the test ends, the service has gone), ws reports an error no one awaits.
this.on('error', () => {});
}
}
const socket = new HocuspocusProviderWebsocket({ url: `ws://127.0.0.1:${server.port}/sync`, WebSocketPolyfill: Browser, ...(eager ? { delay: 0, minDelay: 0, jitter: false } : {}) });
const closings: string[] = [];
const refusals: string[] = [];
const messages: unknown[] = [];
return new Promise((resolve, reject) => {
let synced = false;
const timer = setTimeout(() => {
provider.destroy();
socket.destroy();
reject(new Error('not synced within 3 s'));
}, 3_000);
const provider = new HocuspocusProvider({
websocketProvider: socket,
name: project,
document: doc,
token: 'cookie',
onSynced: () => {
if (synced) return;
synced = true;
clearTimeout(timer);
resolve({ doc, provider, closings, refusals, messages, close: () => (provider.destroy(), socket.destroy()) });
},
onAuthenticationFailed: ({ reason }) => {
if (synced) {
refusals.push(reason);
return;
}
clearTimeout(timer);
provider.destroy();
socket.destroy();
reject(new Error(`refused: ${reason}`));
},
onStateless: ({ payload }) => messages.push(JSON.parse(payload)),
onClose: ({ event }) => {
if (synced) closings.push(event.reason);
else reject(new Error(`closed: ${event.reason}`));
},
});
provider.attach();
});
}
/** Signed in with a fresh ticket, then in the project. */
export async function enter(server: TestServer, project: string, person: Person = {}, options: Omit<JoinOptions, 'cookie'> = {}): Promise<Client> {
return join(server, project, { ...options, cookie: await signIn(server, project, person) });
}
export const until = async (condition: () => boolean | Promise<boolean>, milliseconds = 5_000) => {
for (let waited = 0; !(await condition()); waited += 25) {
if (waited > milliseconds) throw new Error('timed out');
await new Promise((resolve) => setTimeout(resolve, 25));
}
};