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:
@@ -8,23 +8,26 @@
|
||||
// answers, and the log is JSON.
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
|
||||
import pg from 'pg';
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import WebSocket from 'ws';
|
||||
import * as Y from 'yjs';
|
||||
import { addDevice, connect, emptyProject, readProject, ROOTS, topologyView, writeProject } from '@mdg-filius/doc';
|
||||
import { COMPACT_AFTER_IDLE_MILLISECONDS, COMPACT_AFTER_UPDATES, JANITOR_DELAY_MILLISECONDS, PostgresStore, startServer, type Access, type FiliusServer, type Log } from '../src/index.js';
|
||||
import { COMPACT_AFTER_IDLE_MILLISECONDS, COMPACT_AFTER_UPDATES, JANITOR_DELAY_MILLISECONDS, PostgresSessions, PostgresStore, type DocStore } from '../src/index.js';
|
||||
import { testDatabase, type TestDatabase } from './database.js';
|
||||
import { enter, newProject as makeProject, startTestServer, until, type TestServer } from './harness.js';
|
||||
|
||||
let database: TestDatabase;
|
||||
let store: PostgresStore;
|
||||
let sessions: PostgresSessions;
|
||||
let sql: pg.Pool;
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await testDatabase();
|
||||
store = PostgresStore.connect(database.url);
|
||||
sessions = new PostgresSessions(store.pool);
|
||||
await store.migrate();
|
||||
await sessions.migrate();
|
||||
sql = new pg.Pool({ connectionString: database.url });
|
||||
}, 60_000);
|
||||
|
||||
@@ -34,82 +37,12 @@ afterAll(async () => {
|
||||
await database?.stop();
|
||||
});
|
||||
|
||||
/** The log's lines, kept. */
|
||||
function memoryLog(): Log & { readonly lines: Record<string, unknown>[] } {
|
||||
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') };
|
||||
}
|
||||
const start = (options: { readonly idle?: number; readonly store?: DocStore } = {}): Promise<TestServer> =>
|
||||
startTestServer(options.store ?? store, sessions, options.idle === undefined ? {} : { compactAfterIdleMilliseconds: options.idle });
|
||||
const newProject = () => makeProject(store);
|
||||
/** A person signed in to the project with a write grant (a reader with `read`), in it. */
|
||||
const join = (server: TestServer, project: string, eager = false, level: 'read' | 'write' = 'write') => enter(server, project, { level }, { eager });
|
||||
|
||||
async function start(options: { readonly access?: Access; readonly idle?: number } = {}): Promise<FiliusServer & { readonly log: ReturnType<typeof memoryLog> }> {
|
||||
const log = memoryLog();
|
||||
const server = await startServer({
|
||||
port: 0,
|
||||
host: '127.0.0.1',
|
||||
store,
|
||||
log,
|
||||
authenticate: async () => options.access ?? { readOnly: false },
|
||||
...(options.idle === undefined ? {} : { compactAfterIdleMilliseconds: options.idle }),
|
||||
});
|
||||
return Object.assign(server, { log });
|
||||
}
|
||||
|
||||
/** A new project as the service makes it: an empty FILIUS project. */
|
||||
async function newProject(): 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;
|
||||
}
|
||||
|
||||
interface Client {
|
||||
readonly doc: Y.Doc;
|
||||
readonly provider: HocuspocusProvider;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
/** A person in a project: synced, or refused. `eager` comes back at once when let go (no delay, as a client may). */
|
||||
function join(server: FiliusServer, project: string, eager = false): Promise<Client> {
|
||||
const doc = new Y.Doc();
|
||||
const socket = new HocuspocusProviderWebsocket({ url: `ws://127.0.0.1:${server.port}/sync`, WebSocketPolyfill: WebSocket, ...(eager ? { delay: 0, minDelay: 0, jitter: false } : {}) });
|
||||
return new Promise((resolve, reject) => {
|
||||
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: 'test',
|
||||
onSynced: () => {
|
||||
clearTimeout(timer);
|
||||
resolve({ doc, provider, close: () => (provider.destroy(), socket.destroy()) });
|
||||
},
|
||||
onAuthenticationFailed: ({ reason }) => {
|
||||
clearTimeout(timer);
|
||||
provider.destroy();
|
||||
socket.destroy();
|
||||
reject(new Error(`refused: ${reason}`));
|
||||
},
|
||||
onClose: () => reject(new Error('closed')),
|
||||
});
|
||||
provider.attach();
|
||||
});
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
};
|
||||
const deviceCount = (doc: Y.Doc) => doc.getMap(ROOTS.nodes).size;
|
||||
const NAMES = { pc: 'Rechner', networkSwitch: 'Switch' } as const;
|
||||
/** A device as the palette puts one down. */
|
||||
@@ -125,7 +58,7 @@ describe('server F keeps the shared documents', () => {
|
||||
await until(async () => (await store.pending(project)) >= 2);
|
||||
first.close();
|
||||
// The last person left: compacted and unloaded.
|
||||
await until(() => server.sync.getDocumentsCount() === 0);
|
||||
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||
expect(await store.pending(project)).toBe(0);
|
||||
await server.close();
|
||||
|
||||
@@ -186,7 +119,7 @@ describe('server F keeps the shared documents', () => {
|
||||
const person = await join(server, project);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
person.close();
|
||||
await until(() => server.sync.getDocumentsCount() === 0);
|
||||
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||
expect(await rev()).toBe(0);
|
||||
expect(server.log.lines.some((line) => line.event === 'doc.compacted')).toBe(false);
|
||||
await server.close();
|
||||
@@ -214,7 +147,7 @@ describe('server F keeps the shared documents', () => {
|
||||
await until(async () => (await store.pending(project)) === 1);
|
||||
await store.remove(project);
|
||||
person.close();
|
||||
await until(() => server.sync.getDocumentsCount() === 0);
|
||||
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||
expect(server.log.lines.filter((line) => line.event === 'store.failed').map((line) => line.project)).toEqual([project]);
|
||||
expect(await store.load(project)).toBeUndefined();
|
||||
await server.close();
|
||||
@@ -312,13 +245,14 @@ describe('server F keeps the shared documents', () => {
|
||||
expect([COMPACT_AFTER_UPDATES, COMPACT_AFTER_IDLE_MILLISECONDS, JANITOR_DELAY_MILLISECONDS]).toEqual([500, 60_000, 250]);
|
||||
});
|
||||
|
||||
it('refuses a project it does not know, and makes none', async () => {
|
||||
const server = await start();
|
||||
const project = randomUUID();
|
||||
it('refuses a project its store does not have when it comes to load it, and makes none', async () => {
|
||||
// Deleted between the sign-in and the loading: the store has it no more.
|
||||
const project = await newProject();
|
||||
const forgetful = Object.assign(Object.create(store) as PostgresStore, { load: async () => undefined });
|
||||
const server = await start({ store: forgetful });
|
||||
await expect(join(server, project)).rejects.toThrow('refused: unknownProject');
|
||||
expect(await store.load(project)).toBeUndefined();
|
||||
await expect(join(server, 'not-a-project')).rejects.toThrow('refused: notAProject');
|
||||
expect(server.log.lines.some((line) => line.event === 'doc.unknown' && line.project === project)).toBe(true);
|
||||
expect(server.sync.hocuspocus.getDocumentsCount()).toBe(0);
|
||||
await server.close();
|
||||
});
|
||||
|
||||
@@ -330,8 +264,8 @@ describe('server F keeps the shared documents', () => {
|
||||
await until(async () => (await store.pending(project)) >= 1);
|
||||
writer.close();
|
||||
await writerServer.close();
|
||||
const server = await start({ access: { readOnly: true } });
|
||||
const reader = await join(server, project);
|
||||
const server = await start();
|
||||
const reader = await join(server, project, false, 'read');
|
||||
expect(deviceCount(reader.doc)).toBe(1);
|
||||
place(reader.doc, 'switch', 'networkSwitch', 0, 'reader');
|
||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||
@@ -356,5 +290,6 @@ describe('server F keeps the shared documents', () => {
|
||||
expect(refused).toBe(404);
|
||||
await server.close();
|
||||
expect(server.log.lines.map((line) => line.event)).toEqual(['server.listening', 'server.closed']);
|
||||
expect(server.log.lines[0]).toEqual({ level: 'info', event: 'server.listening', port: server.port });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user