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:
+14
-1
@@ -176,6 +176,7 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
|||||||
| `src/gnutella.ts` | Java `GUIApplicationPeerToPeerAnwendungWindow` (gemessen mit `GnutellaWindowRun`) | neu (F4.6g4): der Bildschirmstand von Gnutella — die bekannten Teilnehmer, die Ergebnisse wie die Tabelle sie zeigt, die Dateien von `/peer2peer`, die Zahl der Teilnehmer |
|
| `src/gnutella.ts` | Java `GUIApplicationPeerToPeerAnwendungWindow` (gemessen mit `GnutellaWindowRun`) | neu (F4.6g4): der Bildschirmstand von Gnutella — die bekannten Teilnehmer, die Ergebnisse wie die Tabelle sie zeigt, die Dateien von `/peer2peer`, die Zahl der Teilnehmer |
|
||||||
| `src/viewers.ts`, `src/sim.ts` (`sat`, `nat`, `drop`, `dropped`, `clock`) | Java `SatViewer`, `NatViewer`, `SimulationControl` (gemessen mit `ViewersRun`) | neu (F4.6g5): die Tabellen eines Switches und eines Heimrouters als Bildschirmstände; die Nachricht `drop` (Javas „Pakete verwerfen“ gedrückt oder losgelassen), das Thema `dropped` mit den verworfenen Frames; der Zustand trägt `clock`, die Uhrzeit, für die die Zeit 0 der Simulation steht |
|
| `src/viewers.ts`, `src/sim.ts` (`sat`, `nat`, `drop`, `dropped`, `clock`) | Java `SatViewer`, `NatViewer`, `SimulationControl` (gemessen mit `ViewersRun`) | neu (F4.6g5): die Tabellen eines Switches und eines Heimrouters als Bildschirmstände; die Nachricht `drop` (Javas „Pakete verwerfen“ gedrückt oder losgelassen), das Thema `dropped` mit den verworfenen Frames; der Zustand trägt `clock`, die Uhrzeit, für die die Zeit 0 der Simulation steht |
|
||||||
| `src/sim.ts` (`clearCapture`) | Java `AggregatedMessageTable` („Tabellen löschen“, `Lauscher.reset`) | neu (F4.7): die Nachricht `clearCapture` |
|
| `src/sim.ts` (`clearCapture`) | Java `AggregatedMessageTable` („Tabellen löschen“, `Lauscher.reset`) | neu (F4.7): die Nachricht `clearCapture` |
|
||||||
|
| `src/sync.ts` | Plan §8.5 | neu (F5.2): was F einem Editor über seinen Platz im gemeinsamen Dokument sagt — warum eine Verbindung abgewiesen wird (`notAProject`, `notSignedIn`, `noAccess`, `unknownProject`), warum F sie schließt (`revoked`, `deleted`), und die Nachricht `access`, wenn sich der Zugang einer bleibenden Verbindung ändert (eingefroren, aufgetaut) |
|
||||||
|
|
||||||
## packages/sim
|
## packages/sim
|
||||||
|
|
||||||
@@ -263,7 +264,19 @@ Swift-Pfade relativ zu `filius-ipad/ios/FiliusPad/TopologyEditor/` (Stand
|
|||||||
| Datei | Quelle | Art der Übertragung |
|
| Datei | Quelle | Art der Übertragung |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `src/sync.ts`, `src/persistence.ts`, `src/store.ts`, `src/server.ts`, `src/log.ts`, `src/main.ts`, `src/index.ts` | Plan §5.3, §5.4, §8.2; Hocuspocus 3.4.4 (MIT), `ws` (MIT), `pg` (MIT) | neu (F5.1): Server F mit den gemeinsamen Dokumenten — Hocuspocus auf `/sync`, ein Projekt je Dokument, nur Projekte, die der Speicher kennt (Absage mit Grund `unknownProject` bzw. `notAProject`); jede Aktualisierung sofort in Postgres (`filius_doc_update`, der Reihe nach), verdichtet nach 500 Aktualisierungen, eine Minute nach der letzten Änderung und wenn die letzte Person geht (`filius_doc`, `FOR UPDATE` auf die beim Anlegen geschriebene Zeile); ein Projekt, das während des Bearbeitens verschwindet, wird nicht still neu geschrieben, sondern im Protokoll gemeldet; der Hausmeister läuft nur hier, beim Laden und 250 ms nach jeder fremden Änderung; Lesestufe kann nichts ändern; `/healthz`; Protokoll als JSON-Zeilen. Beim Schließen nimmt der Dienst niemanden mehr an und schließt auch die WebSockets (Hocuspocus' eigenes Schließen ist eine Nachricht, nach der sich ein Client auf demselben Socket wieder anmeldet). Hocuspocus 4 verlangt Node 22; 3.4.4 läuft auf Node 20 und 22 |
|
| `src/sync.ts`, `src/persistence.ts`, `src/store.ts`, `src/server.ts`, `src/log.ts`, `src/main.ts`, `src/index.ts` | Plan §5.3, §5.4, §8.2; Hocuspocus 3.4.4 (MIT), `ws` (MIT), `pg` (MIT) | neu (F5.1): Server F mit den gemeinsamen Dokumenten — Hocuspocus auf `/sync`, ein Projekt je Dokument, nur Projekte, die der Speicher kennt (Absage mit Grund `unknownProject` bzw. `notAProject`); jede Aktualisierung sofort in Postgres (`filius_doc_update`, der Reihe nach), verdichtet nach 500 Aktualisierungen, eine Minute nach der letzten Änderung und wenn die letzte Person geht (`filius_doc`, `FOR UPDATE` auf die beim Anlegen geschriebene Zeile); ein Projekt, das während des Bearbeitens verschwindet, wird nicht still neu geschrieben, sondern im Protokoll gemeldet; der Hausmeister läuft nur hier, beim Laden und 250 ms nach jeder fremden Änderung; Lesestufe kann nichts ändern; `/healthz`; Protokoll als JSON-Zeilen. Beim Schließen nimmt der Dienst niemanden mehr an und schließt auch die WebSockets (Hocuspocus' eigenes Schließen ist eine Nachricht, nach der sich ein Client auf demselben Socket wieder anmeldet). Hocuspocus 4 verlangt Node 22; 3.4.4 läuft auf Node 20 und 22 |
|
||||||
| `test/sync.test.ts`, `test/database.ts` | — | neu (F5.1): echte Clients (Hocuspocus' Provider über WebSocket) gegen eine Wegwerf-Postgres in Docker: Wiederkehr nach Neustart, nie verdichtete Aktualisierungen nach hartem Halt, zwei Personen live, Verdichten (500, Ruhe, nichts ohne Änderung), Schließen mit Personen, die sofort wiederkommen, gelöschtes Projekt, doppeltes Anlegen, Hausmeister beim Laden und nach einer Zusammenführung, unbekanntes Projekt, Lesestufe, `/healthz`, Protokoll |
|
| `src/ticket.ts` | itsdangerous 2.2.0 (BSD-3-Clause) `URLSafeTimedSerializer`, `TimestampSigner`, `Signer` (django-concat, HMAC-SHA1); Dashboard `backend/modules/scratch/src/ticket.py` | neu (F5.2): die Eintrittskarte des Dashboards in Node geprüft, wie itsdangerous sie prüft (zlib-gepackte Nutzlast mit „.“, Zeitstempel als kürzeste Bytes, Signatur zuerst, dann das Alter: über 60 s oder unter 0 abgelehnt); die Ansprüche Person, Login, Name, Farbe, Sprache, Projekt, Stufe — ohne Person, Login, Projekt oder gültige Stufe keine Karte. Strenger als Python: eine Signatur mit fremden Zeichen, die Pythons Base64 überliest, wird abgelehnt (itsdangerous erzeugt sie nie) |
|
||||||
|
| `src/sessions.ts`, `src/auth.ts` | Scratch-Editor `dashboard_auth_routes.py`, `models/session.py` | neu (F5.2): `/__auth` tauscht die Karte gegen einen Keks (`filius_session`, HttpOnly, SameSite=Lax, über HTTPS `Secure`, 12 h) und schickt den Browser nach `next` — nur an einen Ort von F selbst, aufgelöst wie im Browser: `/\…` und `/<Tab>/…` führen nicht weg (Scratchs Prüfung lässt beide durch, Werkzeug reicht sie roh weiter, der Browser liest `//…` — gemessen), und weil F den aufgelösten Pfad weitergibt, auch nicht `/.//…`, das die Auflösung zu `//…` kürzt; F speichert nur den SHA-256 des Schlüssels; ein zweites Projekt im selben Browser hängt eine Berechtigung an die Sitzung, eine Karte einer anderen Person beendet die alte Sitzung; jede Karte verlängert, abgelaufene werden gelöscht |
|
||||||
|
| `src/sync.ts` (Anmeldung), `src/server.ts` (Wege, Herkunft) | Plan §8.5 | geändert (F5.2): `/sync` entscheidet nach der Berechtigung der Sitzung, die der Keks nennt — keine, Lesen, Schreiben; ein eingefrorenes Projekt lesen alle; jede Absage mit Grund und im Protokoll; ein WebSocket von einer Seite fremder Herkunft wird abgewiesen (eine Seite anderswo unter derselben Domain brächte den Keks mit, SameSite trennt nur Sites); unerwartete Fehler eines Wegs werden 500 und stehen im Protokoll |
|
||||||
|
| `src/service.ts`, `src/store.ts` (`freeze`, `remove`), `src/persistence.ts` (`discard`), `src/main.ts` | Dashboard `backend/modules/scratch/src/service.py`, Scratch-Editor `revoke`, `active_sessions` | neu (F5.2): `/svc/*` mit Dienstschlüssel (in konstanter Zeit verglichen): Projekt anlegen (leeres FILIUS-Projekt), löschen (erst nicht mehr gespeichert, dann weg, dann alle getrennt — wer wiederkommt, findet kein Projekt), einfrieren und auftauen (wer drin ist, liest bzw. schreibt wieder und erfährt es), Berechtigung einer Person an einem Projekt oder alle ihre Sitzungen beenden (wer verbunden ist, wird getrennt und beim Wiederkommen abgewiesen), die Sitzungen auflisten; ohne Geheimnisse startet der Dienst nicht |
|
||||||
|
| `test/ticket.test.ts`, `test/tickets.ts`, `test/tickets.python.json` | `tools/ticket-oracle` | neu (F5.2): jede Karte des Python-Orakels liest Node wie Python (Nutzlast oder derselbe Grund der Ablehnung); in Node signierte Karten tragen dasselbe JSON zum selben Zeitpunkt (Nodes zlib packt anders, ungepackt byte-gleich); die Ansprüche |
|
||||||
|
| `test/access.test.ts`, `test/harness.ts` | — | neu (F5.2): `/__auth` (Keks, `next`, schlechte Karten, unbekanntes Projekt, zweites Projekt, nächste Person, Verlängerung), Absagen an `/sync` mit Grund, Herkunft, Dienstweg (Schlüssel, Anlegen, Fehlerantworten, Löschen, Einfrieren, Widerruf einzeln und ganz, Sitzungsliste, 500) |
|
||||||
|
| `test/sync.test.ts`, `test/database.ts` | — | neu (F5.1), seit F5.2 mit echter Anmeldung (Karte, Keks): echte Clients (Hocuspocus' Provider über WebSocket) gegen eine Wegwerf-Postgres in Docker: Wiederkehr nach Neustart, nie verdichtete Aktualisierungen nach hartem Halt, zwei Personen live, Verdichten (500, Ruhe, nichts ohne Änderung), Schließen mit Personen, die sofort wiederkommen, gelöschtes Projekt, doppeltes Anlegen, Hausmeister beim Laden und nach einer Zusammenführung, unbekanntes Projekt, Lesestufe, `/healthz`, Protokoll |
|
||||||
|
|
||||||
|
## tools/ticket-oracle
|
||||||
|
|
||||||
|
Schreibt Eintrittskarten mit itsdangerous 2.2.0 selbst (der Version des
|
||||||
|
Dashboards) bei fester Uhr und hält fest, was itsdangerous beim Lesen
|
||||||
|
antwortet: `packages/server/test/tickets.python.json`.
|
||||||
|
|
||||||
## tools/swift-oracle
|
## tools/swift-oracle
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ die Herkunft je Paket. Lizenz dieses Repos: **GPL-3.0** (`LICENSE`).
|
|||||||
| `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) |
|
| `packages/protocol` | die eine Nachrichtenliste zwischen Editor und Simulationsraum (`/sim`) |
|
||||||
| `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` |
|
| `packages/sim` | Simulationsraum (für Server F und den Web Worker) und `LocalSimHost` |
|
||||||
| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite): Fläche, Palette, Werkzeuge des Entwurfs, Geräte-Blatt, Aktionsmodus (Desktops, Anwendungen, Datenaustausch), Dokumentationsmodus (Text, Gliederungsfläche, Bild exportieren, Bericht als PDF); `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite |
|
| `packages/editor` | Oberfläche im Iframe des Dashboards (React, Vite): Fläche, Palette, Werkzeuge des Entwurfs, Geräte-Blatt, Aktionsmodus (Desktops, Anwendungen, Datenaustausch), Dokumentationsmodus (Text, Gliederungsfläche, Bild exportieren, Bericht als PDF); `npm run dev -w @mdg-filius/editor` öffnet die Entwicklungsseite |
|
||||||
| `packages/server` | Filius-Dienst auf Server F: gemeinsame Dokumente über Hocuspocus (`/sync`), gespeichert in Postgres (Stand und Aktualisierungen, verdichtet), Hausmeister, `/healthz`; `npm run build -w @mdg-filius/server` bündelt ihn nach `dist/main.mjs` *(Anmeldung, Simulationsräume und Dienstweg folgen)* |
|
| `packages/server` | Filius-Dienst auf Server F: gemeinsame Dokumente über Hocuspocus (`/sync`), gespeichert in Postgres (Stand und Aktualisierungen, verdichtet), Hausmeister; Anmeldung mit der Eintrittskarte des Dashboards (`/__auth`, Keks, Lese- und Schreibstufe); Dienstweg des Dashboards (`/svc/*`: Projekte anlegen, löschen, einfrieren, Zugänge widerrufen); `/healthz`; `npm run build -w @mdg-filius/server` bündelt ihn nach `dist/main.mjs` *(Simulationsräume folgen)* |
|
||||||
|
|
||||||
Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard.
|
Plan und Entscheidungen: `knowledge/plans/FILIUS_WEB_PLAN.md` im Dashboard.
|
||||||
|
|
||||||
@@ -44,5 +44,12 @@ npm run check # Typprüfung + Tests (die Server-Tests starten eine Wegwerf-
|
|||||||
npm run test:browser # Tests im echten Browser (Playwright: Chromium; der Touch-Test auch in WebKit als iPad hoch und quer)
|
npm run test:browser # Tests im echten Browser (Playwright: Chromium; der Touch-Test auch in WebKit als iPad hoch und quer)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Server F braucht zum Start `DATABASE_URL`, `FILIUS_TICKET_SECRET` (mit dem
|
||||||
|
Dashboard geteilt, das die Karten signiert), `FILIUS_SERVICE_KEY` (der
|
||||||
|
Schlüssel des Dashboards für `/svc/*`) und `FILIUS_ORIGINS` (die Herkünfte
|
||||||
|
des Editors, durch Kommas getrennt); `FILIUS_PORT` ist 8080, wenn nicht
|
||||||
|
gesetzt. Fehlt eine davon, startet er nicht. Ohne HTTPS (Entwicklerrechner)
|
||||||
|
setzt `FILIUS_INSECURE_COOKIE=1` den Keks ohne `Secure`.
|
||||||
|
|
||||||
Code, Kommentare und Testnamen sind englisch; sichtbare Texte stehen hinter
|
Code, Kommentare und Testnamen sind englisch; sichtbare Texte stehen hinter
|
||||||
i18n-Schlüsseln (Deutsch/Englisch).
|
i18n-Schlüsseln (Deutsch/Englisch).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// Public surface of the wire protocol.
|
// Public surface of the wire protocol.
|
||||||
export * from './host.js';
|
export * from './host.js';
|
||||||
export * from './sim.js';
|
export * from './sim.js';
|
||||||
|
export * from './sync.js';
|
||||||
export * from './terminal.js';
|
export * from './terminal.js';
|
||||||
export * from './dns.js';
|
export * from './dns.js';
|
||||||
export * from './echo.js';
|
export * from './echo.js';
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// What F tells an editor about its place in a shared document (/sync, plan
|
||||||
|
// §5, §8.5) besides the document itself — the one list both sides import:
|
||||||
|
// why a connection is refused, why F lets it go, and the message that its
|
||||||
|
// access changed while it is connected (a project frozen or thawed).
|
||||||
|
|
||||||
|
/** Why F refuses a connection to a project (Hocuspocus' permission-denied, with this as its reason). */
|
||||||
|
export const SYNC_REFUSALS = [
|
||||||
|
/** The name is not a project's id. */
|
||||||
|
'notAProject',
|
||||||
|
/** No session (no cookie, or it ran out): the dashboard issues a new ticket. */
|
||||||
|
'notSignedIn',
|
||||||
|
/** A session, but no access to this project (never had one, or it was revoked). */
|
||||||
|
'noAccess',
|
||||||
|
/** F does not know the project (deleted). */
|
||||||
|
'unknownProject',
|
||||||
|
] as const;
|
||||||
|
export type SyncRefusal = (typeof SYNC_REFUSALS)[number];
|
||||||
|
|
||||||
|
/** Why F closes a connection (Hocuspocus' close, with this as its reason). */
|
||||||
|
export const SYNC_CLOSINGS = [
|
||||||
|
/** The person's access to the project was taken away. */
|
||||||
|
'revoked',
|
||||||
|
/** The project was deleted. */
|
||||||
|
'deleted',
|
||||||
|
] as const;
|
||||||
|
export type SyncClosing = (typeof SYNC_CLOSINGS)[number];
|
||||||
|
|
||||||
|
export const SYNC_PROTOCOL_VERSION = 1;
|
||||||
|
|
||||||
|
/** A stateless message from F: the connection's access now (sent when it changes on a connection that stays). */
|
||||||
|
export interface AccessMessage {
|
||||||
|
readonly v: 1;
|
||||||
|
readonly kind: 'access';
|
||||||
|
readonly readOnly: boolean;
|
||||||
|
/** The project is frozen (its deadline passed): no one writes, whatever their level. */
|
||||||
|
readonly frozen: boolean;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
// Server F (plan §8): the shared documents, and later the simulation rooms,
|
// Server F (plan §8): the shared documents, the sign-in and the dashboard's
|
||||||
// the sign-in and the dashboard's service.
|
// service; later the simulation rooms.
|
||||||
|
export * from './auth.js';
|
||||||
export * from './log.js';
|
export * from './log.js';
|
||||||
export * from './persistence.js';
|
export * from './persistence.js';
|
||||||
export * from './server.js';
|
export * from './server.js';
|
||||||
|
export * from './service.js';
|
||||||
|
export * from './sessions.js';
|
||||||
export * from './store.js';
|
export * from './store.js';
|
||||||
export * from './sync.js';
|
export * from './sync.js';
|
||||||
|
export * from './ticket.js';
|
||||||
|
|||||||
+22
-13
@@ -1,29 +1,38 @@
|
|||||||
// Server F started from its environment: FILIUS_PORT (8080), DATABASE_URL.
|
// Server F started from its environment: FILIUS_PORT (8080), DATABASE_URL,
|
||||||
// Until the sign-in comes (F5.2) every connection is refused, unless
|
// FILIUS_TICKET_SECRET (shared with the dashboard, which signs the tickets),
|
||||||
// FILIUS_INSECURE_DEV=1 lets everyone write — on a developer's machine only.
|
// 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 { jsonLog } from './log.js';
|
||||||
import { startServer } from './server.js';
|
import { startServer } from './server.js';
|
||||||
|
import { PostgresSessions } from './sessions.js';
|
||||||
import { PostgresStore } from './store.js';
|
import { PostgresStore } from './store.js';
|
||||||
|
|
||||||
const log = jsonLog();
|
const log = jsonLog();
|
||||||
const url = process.env.DATABASE_URL;
|
const config = Object.fromEntries(['DATABASE_URL', 'FILIUS_TICKET_SECRET', 'FILIUS_SERVICE_KEY', 'FILIUS_ORIGINS'].map((name) => [name, process.env[name] ?? '']));
|
||||||
if (url === undefined) {
|
const missing = Object.keys(config).filter((name) => config[name] === '');
|
||||||
log.error('config.missing', { variable: 'DATABASE_URL' });
|
if (missing.length > 0) {
|
||||||
|
log.error('config.missing', { variables: missing });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
const insecure = process.env.FILIUS_INSECURE_DEV === '1';
|
const secureCookie = process.env.FILIUS_INSECURE_COOKIE !== '1';
|
||||||
if (insecure) log.warn('auth.insecureDev', { note: 'every connection may write' });
|
if (!secureCookie) log.warn('auth.insecureCookie', { note: 'the session cookie goes over plain HTTP' });
|
||||||
const store = PostgresStore.connect(url);
|
const store = PostgresStore.connect(config.DATABASE_URL!);
|
||||||
|
const sessions = new PostgresSessions(store.pool);
|
||||||
await store.migrate();
|
await store.migrate();
|
||||||
|
await sessions.migrate();
|
||||||
const server = await startServer({
|
const server = await startServer({
|
||||||
port: Number(process.env.FILIUS_PORT ?? 8080),
|
port: Number(process.env.FILIUS_PORT ?? 8080),
|
||||||
store,
|
store,
|
||||||
|
sessions,
|
||||||
log,
|
log,
|
||||||
authenticate: async () => {
|
ticketSecret: config.FILIUS_TICKET_SECRET!,
|
||||||
if (insecure) return { readOnly: false };
|
serviceKey: config.FILIUS_SERVICE_KEY!,
|
||||||
throw new Error('no sign-in yet');
|
origins: config.FILIUS_ORIGINS!.split(',').map((origin) => origin.trim()),
|
||||||
},
|
secureCookie,
|
||||||
});
|
});
|
||||||
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
||||||
process.once(signal, () => {
|
process.once(signal, () => {
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
// service asks (a minute after the last change, and when the last person
|
// 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
|
// 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
|
// 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 * as Y from 'yjs';
|
||||||
import { errorText, type Log } from './log.js';
|
import { errorText, type Log } from './log.js';
|
||||||
@@ -21,6 +22,7 @@ export class DocumentPersistence {
|
|||||||
private sinceCompaction = 0;
|
private sinceCompaction = 0;
|
||||||
/** Updates queued since the last compaction was queued: what decides the next one. */
|
/** Updates queued since the last compaction was queued: what decides the next one. */
|
||||||
private queued = 0;
|
private queued = 0;
|
||||||
|
private discarded = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly store: DocStore,
|
private readonly store: DocStore,
|
||||||
@@ -47,7 +49,7 @@ export class DocumentPersistence {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private enqueue(work: () => Promise<void>): Promise<void> {
|
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;
|
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. */
|
/** Everything stored that was asked for. */
|
||||||
flush(): Promise<void> {
|
flush(): Promise<void> {
|
||||||
return this.enqueue(async () => {});
|
return this.enqueue(async () => {});
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
// Server F's front door (plan §8.2): one HTTP server — /healthz for the
|
// 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
|
// proxy, /__auth for the editor's frame (ticket in, cookie out), /svc/* for
|
||||||
// found. Closed, it first takes no one new (a client lets go and comes back
|
// the dashboard (with the service key), the WebSocket /sync for the shared
|
||||||
// at once, which would keep its document open), then lets everyone go,
|
// documents; anything else is not found. A WebSocket from a page of another
|
||||||
// waits until every document is stored and unloaded, and stops.
|
// 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 { AddressInfo, Socket } from 'node:net';
|
||||||
import type { Hocuspocus } from '@hocuspocus/server';
|
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import type { Log } from './log.js';
|
import { exchangeTicket } from './auth.js';
|
||||||
import { createSync, type SyncOptions } from './sync.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 {
|
export interface FiliusServer {
|
||||||
readonly port: number;
|
readonly port: number;
|
||||||
readonly sync: Hocuspocus;
|
readonly sync: Sync;
|
||||||
close(): Promise<void>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,25 +26,40 @@ export interface ServerOptions extends SyncOptions {
|
|||||||
readonly port?: number;
|
readonly port?: number;
|
||||||
readonly host?: string;
|
readonly host?: string;
|
||||||
readonly log: Log;
|
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 {
|
function refuse(socket: Socket, status: string): void {
|
||||||
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
|
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> {
|
export async function startServer(options: ServerOptions): Promise<FiliusServer> {
|
||||||
const sync = createSync(options);
|
const sync = createSync(options);
|
||||||
|
const { hocuspocus } = sync;
|
||||||
const sockets = new WebSocketServer({ noServer: true });
|
const sockets = new WebSocketServer({ noServer: true });
|
||||||
let closing = false;
|
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 http: Server = createServer((request, response) => {
|
||||||
const path = new URL(request.url ?? '/', 'http://filius').pathname;
|
const path = new URL(request.url ?? '/', 'http://filius').pathname;
|
||||||
if (request.method === 'GET' && path === '/healthz') {
|
handle(request, response, path).catch((error: unknown) => {
|
||||||
response.writeHead(200, { 'content-type': 'application/json' });
|
options.log.error('http.failed', { path, error: errorText(error) });
|
||||||
response.end(JSON.stringify({ status: 'ok', documents: sync.getDocumentsCount(), connections: sync.getConnectionsCount() }));
|
if (!response.headersSent) json(response, 500, { error: 'internal' });
|
||||||
return;
|
else response.end();
|
||||||
}
|
});
|
||||||
response.writeHead(404, { 'content-type': 'application/json' });
|
|
||||||
response.end(JSON.stringify({ error: 'notFound' }));
|
|
||||||
});
|
});
|
||||||
http.on('upgrade', (request, socket, head) => {
|
http.on('upgrade', (request, socket, head) => {
|
||||||
const path = new URL(request.url ?? '/', 'http://filius').pathname;
|
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');
|
refuse(socket as Socket, '404 Not Found');
|
||||||
return;
|
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) {
|
if (closing) {
|
||||||
refuse(socket as Socket, '503 Service Unavailable');
|
refuse(socket as Socket, '503 Service Unavailable');
|
||||||
return;
|
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));
|
await new Promise<void>((resolve) => http.listen(options.port ?? 0, options.host ?? '0.0.0.0', resolve));
|
||||||
const port = (http.address() as AddressInfo).port;
|
const port = (http.address() as AddressInfo).port;
|
||||||
@@ -61,14 +87,14 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
|
|||||||
sync,
|
sync,
|
||||||
async close() {
|
async close() {
|
||||||
closing = true;
|
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.
|
// 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');
|
for (const socket of sockets.clients) socket.close(1001, 'service stopping');
|
||||||
// Each document is stored and unloaded once its last person has gone.
|
// 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();
|
sockets.close();
|
||||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||||
options.log.info('server.closed', { documents: sync.getDocumentsCount() });
|
options.log.info('server.closed', { documents: hocuspocus.getDocumentsCount() });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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 })) };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
// filius_doc, the updates since then in filius_doc_update, in the order they
|
// 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
|
// 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
|
// 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';
|
import pg from 'pg';
|
||||||
|
|
||||||
@@ -25,9 +26,12 @@ export interface DocStore {
|
|||||||
append(project: string, update: Uint8Array): Promise<string>;
|
append(project: string, update: Uint8Array): Promise<string>;
|
||||||
/** A new state, which holds every update up to `upToSeq`: those go. */
|
/** A new state, which holds every update up to `upToSeq`: those go. */
|
||||||
compact(project: string, state: Uint8Array, upToSeq: string): Promise<void>;
|
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. */
|
/** How many updates wait for the next compaction. */
|
||||||
pending(project: string): Promise<number>;
|
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>;
|
close(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,12 +48,13 @@ CREATE TABLE IF NOT EXISTS filius_doc_update (
|
|||||||
update bytea NOT NULL,
|
update bytea NOT NULL,
|
||||||
PRIMARY KEY (project_uuid, seq)
|
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);
|
const bytes = (buffer: Buffer) => new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
|
||||||
|
|
||||||
export class PostgresStore implements DocStore {
|
export class PostgresStore implements DocStore {
|
||||||
constructor(private readonly pool: pg.Pool) {}
|
constructor(readonly pool: pg.Pool) {}
|
||||||
|
|
||||||
static connect(url: string): PostgresStore {
|
static connect(url: string): PostgresStore {
|
||||||
return new PostgresStore(new pg.Pool({ connectionString: url }));
|
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> {
|
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]);
|
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);
|
return Number(result.rows[0]!.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(project: string): Promise<void> {
|
async remove(project: string): Promise<boolean> {
|
||||||
await this.pool.query('DELETE FROM filius_doc WHERE project_uuid = $1', [project]);
|
const result = await this.pool.query('DELETE FROM filius_doc WHERE project_uuid = $1', [project]);
|
||||||
|
return result.rowCount === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
|
|||||||
+60
-20
@@ -1,19 +1,24 @@
|
|||||||
// The shared documents (/sync, plan §5): Hocuspocus, a project a document.
|
// 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
|
// Who comes in is decided by the session the browser's cookie names (plan
|
||||||
// the store has not got is refused, with its reason: projects are made
|
// §8.5): its grant to the project, read or write; a frozen project is read
|
||||||
// through the service, F5.2), every
|
// 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
|
// update stored as it comes, compacted a minute after the last change, when
|
||||||
// the last person leaves and after COMPACT_AFTER_UPDATES updates. The
|
// 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
|
// 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
|
// document is loaded, then 250 ms after the last update that was not its
|
||||||
// own; its repair goes to everyone like any update.
|
// 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 type * as Y from 'yjs';
|
||||||
import { JANITOR_ORIGIN, repair } from '@mdg-filius/doc';
|
import { JANITOR_ORIGIN, repair } from '@mdg-filius/doc';
|
||||||
|
import type { AccessMessage, SyncRefusal } from '@mdg-filius/protocol';
|
||||||
import { errorText, type Log } from './log.js';
|
import { errorText, type Log } from './log.js';
|
||||||
import { DocumentPersistence } from './persistence.js';
|
import { DocumentPersistence } from './persistence.js';
|
||||||
|
import type { SessionPerson, SessionStore } from './sessions.js';
|
||||||
import type { DocStore } from './store.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). */
|
/** How long after the last change a document is compacted (Hocuspocus' debounce of onStoreDocument). */
|
||||||
export const COMPACT_AFTER_IDLE_MILLISECONDS = 60_000;
|
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). */
|
/** The janitor's wait after an update (entprellt). */
|
||||||
export const JANITOR_DELAY_MILLISECONDS = 250;
|
export const JANITOR_DELAY_MILLISECONDS = 250;
|
||||||
|
|
||||||
/** What a connection may do; thrown, it is refused. */
|
/** The cookie that names a browser's session at F. */
|
||||||
export interface Access {
|
export const SESSION_COOKIE = 'filius_session';
|
||||||
readonly readOnly: boolean;
|
|
||||||
/** Who, for the log. */
|
/** Who is behind a connection, and with which grant (Hocuspocus' context of the connection). */
|
||||||
readonly user?: string;
|
export interface ConnectionContext {
|
||||||
|
readonly person: SessionPerson;
|
||||||
|
readonly level: Level;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SyncOptions {
|
export interface SyncOptions {
|
||||||
readonly store: DocStore;
|
readonly store: DocStore;
|
||||||
|
readonly sessions: SessionStore;
|
||||||
readonly log: Log;
|
readonly log: Log;
|
||||||
readonly authenticate: (payload: onAuthenticatePayload) => Promise<Access>;
|
|
||||||
readonly compactAfterIdleMilliseconds?: number;
|
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`). */
|
/** 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 });
|
return Object.assign(new Error(reason), { reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A project's id: a UUID, as the dashboard makes them. */
|
/** A cookie's value from a Cookie header. */
|
||||||
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);
|
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. */
|
/** The janitor on a document; the function that ends it. */
|
||||||
function startJanitor(doc: Y.Doc, project: string, log: Log): () => void {
|
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 }>();
|
const open = new Map<string, { readonly persistence: DocumentPersistence; stopJanitor?: () => void }>();
|
||||||
return new Hocuspocus({
|
const hocuspocus = new Hocuspocus({
|
||||||
quiet: true,
|
quiet: true,
|
||||||
debounce: compactAfterIdleMilliseconds,
|
debounce: compactAfterIdleMilliseconds,
|
||||||
maxDebounce: COMPACT_AT_LATEST_MILLISECONDS,
|
maxDebounce: COMPACT_AT_LATEST_MILLISECONDS,
|
||||||
async onAuthenticate(payload) {
|
async onAuthenticate({ documentName: project, requestHeaders, connectionConfig }): Promise<ConnectionContext> {
|
||||||
if (!isProjectId(payload.documentName)) throw refusal('notAProject');
|
if (!isUuid(project)) throw refusal('notAProject');
|
||||||
const access = await authenticate(payload);
|
const key = cookieValue(requestHeaders.cookie, SESSION_COOKIE);
|
||||||
payload.connectionConfig.readOnly = access.readOnly;
|
const access = key === undefined ? ({ kind: 'notSignedIn' } as const) : await sessions.access(key, project);
|
||||||
return access;
|
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 }) {
|
async onLoadDocument({ documentName, document }) {
|
||||||
const persistence = new DocumentPersistence(store, documentName, log);
|
const persistence = new DocumentPersistence(store, documentName, log);
|
||||||
@@ -110,4 +143,11 @@ export function createSync({ store, log, authenticate, compactAfterIdleMilliseco
|
|||||||
log.info('doc.unloaded', { project: documentName });
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
// Who comes into a project at F (F5.2) with real clients and a real
|
||||||
|
// Postgres: the dashboard's ticket redeemed at /__auth for a cookie, and
|
||||||
|
// the browser sent on to a place of F's own; the session's grant decides at
|
||||||
|
// /sync — none, read or write —, and a refusal says why; a browser opening a
|
||||||
|
// second project keeps its session, the next person on a shared device gets
|
||||||
|
// one of their own; a page of another origin gets no WebSocket. The
|
||||||
|
// dashboard's service with its key: makes and deletes projects (whoever is
|
||||||
|
// in one is let go), freezes and thaws them (whoever is in one reads, and is
|
||||||
|
// told), ends a person's grant or all their sessions (whoever is connected
|
||||||
|
// is let go and refused when they come back), lists who has which grant.
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import pg from 'pg';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import WebSocket from 'ws';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { addDevice, emptyProject, readProject, ROOTS } from '@mdg-filius/doc';
|
||||||
|
import { SYNC_CLOSINGS, SYNC_REFUSALS } from '@mdg-filius/protocol';
|
||||||
|
import { cookieValue, PostgresSessions, PostgresStore, safeNext, SERVICE_BODY_BYTES, serviceKeyMatches, SESSION_COOKIE, SESSION_SECONDS, sessionCookie, type SessionStore } from '../src/index.js';
|
||||||
|
import { testDatabase, type TestDatabase } from './database.js';
|
||||||
|
import { EDITOR_ORIGIN, enter, exchange, join, newProject as makeProject, SERVICE_KEY, signIn, startTestServer, TICKET_SECRET, until, type TestServer } from './harness.js';
|
||||||
|
import { signTicket } from './tickets.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);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await sql?.end();
|
||||||
|
await store?.close();
|
||||||
|
await database?.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
const newProject = () => makeProject(store);
|
||||||
|
const start = (options: { readonly sessions?: SessionStore; readonly secureCookie?: boolean } = {}) =>
|
||||||
|
startTestServer(store, options.sessions ?? sessions, options.secureCookie === undefined ? {} : { secureCookie: options.secureCookie });
|
||||||
|
|
||||||
|
/** A call of the dashboard's service. */
|
||||||
|
function service(server: TestServer, method: string, path: string, body?: unknown, key: string | null = SERVICE_KEY): Promise<Response> {
|
||||||
|
return fetch(`${server.base}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: { 'content-type': 'application/json', ...(key === null ? {} : { 'x-filius-service-key': key }) },
|
||||||
|
...(body === undefined ? {} : { body: typeof body === 'string' ? body : JSON.stringify(body) }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const answer = async (response: Response) => [response.status, await response.json()] as const;
|
||||||
|
const deviceCount = (doc: Y.Doc) => doc.getMap(ROOTS.nodes).size;
|
||||||
|
const place = (doc: Y.Doc, id: string) => addDevice(doc, { id, kind: 'pc', name: 'Rechner', x: 0, y: 0, newId: randomUUID }, 'person');
|
||||||
|
const sleep = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds));
|
||||||
|
/** The status a WebSocket upgrade to /sync is answered with (101 when it opens). */
|
||||||
|
function upgrade(server: TestServer, headers: Record<string, string>): Promise<number> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new WebSocket(`ws://127.0.0.1:${server.port}/sync`, { headers });
|
||||||
|
socket.on('open', () => {
|
||||||
|
socket.close();
|
||||||
|
resolve(101);
|
||||||
|
});
|
||||||
|
socket.on('unexpected-response', (_request, response) => resolve(response.statusCode ?? 0));
|
||||||
|
socket.on('error', () => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the exchange at /__auth', () => {
|
||||||
|
it('redeems a ticket for a session cookie and sends the browser on', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const project = await newProject();
|
||||||
|
const response = await exchange(server, project, { login: 'ab12', level: 'read' }, '/editor?mode=x#top');
|
||||||
|
expect(response.status).toBe(302);
|
||||||
|
expect(response.headers.get('location')).toBe('/editor?mode=x#top');
|
||||||
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||||
|
const cookie = response.headers.get('set-cookie')!;
|
||||||
|
expect(cookie).toMatch(new RegExp(`^${SESSION_COOKIE}=[A-Za-z0-9_-]{43}; Max-Age=${SESSION_SECONDS}; Path=/; HttpOnly; SameSite=Lax$`));
|
||||||
|
expect(server.log.lines.find((line) => line.event === 'auth.signedIn')).toEqual({ level: 'info', event: 'auth.signedIn', project, user: 'ab12', grant: 'read' });
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets the cookie for HTTPS only, unless told otherwise', async () => {
|
||||||
|
const server = await start({ secureCookie: true });
|
||||||
|
const response = await exchange(server, await newProject());
|
||||||
|
expect(response.headers.get('set-cookie')).toMatch(/; HttpOnly; SameSite=Lax; Secure$/);
|
||||||
|
await server.close();
|
||||||
|
expect(sessionCookie('k', false)).toBe(`${SESSION_COOKIE}=k; Max-Age=43200; Path=/; HttpOnly; SameSite=Lax`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a bad ticket without saying why, and says why in the log', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const project = await newProject();
|
||||||
|
const old = signTicket({ u: randomUUID(), n: 'ab12', p: project, lvl: 'write' }, TICKET_SECRET, Math.floor(Date.now() / 1000) - 61);
|
||||||
|
const forged = signTicket({ u: randomUUID(), n: 'ab12', p: project, lvl: 'write' }, 'guessed');
|
||||||
|
for (const ticket of [old, forged, '']) {
|
||||||
|
const response = await fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}`, { redirect: 'manual' });
|
||||||
|
expect(await answer(response)).toEqual([401, { error: 'invalidTicket' }]);
|
||||||
|
expect(response.headers.get('set-cookie')).toBeNull();
|
||||||
|
}
|
||||||
|
expect((await fetch(`${server.base}/__auth`, { redirect: 'manual' })).status).toBe(401);
|
||||||
|
// Only a GET exchanges: a ticket sent otherwise is not looked at.
|
||||||
|
const ticket = signTicket({ u: randomUUID(), n: 'ab12', p: project, lvl: 'write' }, TICKET_SECRET);
|
||||||
|
expect(await answer(await fetch(`${server.base}/__auth?ticket=${encodeURIComponent(ticket)}`, { method: 'POST', redirect: 'manual' }))).toEqual([404, { error: 'notFound' }]);
|
||||||
|
expect(server.log.lines.filter((line) => line.event === 'auth.ticketRefused').map((line) => line.reason)).toEqual(['ticket 61 s old', 'signature does not match', 'no signature', 'no signature']);
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a ticket to a project F does not know', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const project = randomUUID();
|
||||||
|
expect(await answer(await exchange(server, project, { login: 'ab12' }))).toEqual([404, { error: 'unknownProject' }]);
|
||||||
|
expect(server.log.lines.find((line) => line.event === 'auth.unknownProject')).toMatchObject({ project, user: 'ab12' });
|
||||||
|
expect((await sql.query('SELECT count(*)::int AS n FROM filius_grant WHERE project_uuid = $1', [project])).rows[0].n).toBe(0);
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['/editor', '/editor'],
|
||||||
|
['/a/b?c=d#e', '/a/b?c=d#e'],
|
||||||
|
['relative', '/relative'],
|
||||||
|
[null, '/'],
|
||||||
|
['', '/'],
|
||||||
|
['//elsewhere.test/x', '/'],
|
||||||
|
['/\\elsewhere.test/x', '/'],
|
||||||
|
['/\t/elsewhere.test', '/'],
|
||||||
|
['/.//elsewhere.test', '/'],
|
||||||
|
['https://elsewhere.test/', '/'],
|
||||||
|
['javascript:alert(1)', '/'],
|
||||||
|
['http://filius.invalid/x', '/x'],
|
||||||
|
])('sends the browser after the exchange from %j to %j', (next, place) => {
|
||||||
|
expect(safeNext(next)).toBe(place);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the session when the same browser opens a second project, and takes the newer level', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const [first, second] = [await newProject(), await newProject()];
|
||||||
|
const user = randomUUID();
|
||||||
|
const cookie = await signIn(server, first, { user });
|
||||||
|
const again = await exchange(server, second, { user, cookie, level: 'read' });
|
||||||
|
expect(again.headers.get('set-cookie')!.split(';')[0]).toBe(cookie);
|
||||||
|
const a = await join(server, first, { cookie });
|
||||||
|
const b = await join(server, second, { cookie });
|
||||||
|
expect([a.provider.authorizedScope, b.provider.authorizedScope]).toEqual(['read-write', 'readonly']);
|
||||||
|
// The same project with a read ticket: from now on it is read.
|
||||||
|
await exchange(server, first, { user, cookie, level: 'read' });
|
||||||
|
const c = await join(server, first, { cookie });
|
||||||
|
expect(c.provider.authorizedScope).toBe('readonly');
|
||||||
|
for (const each of [a, b, c]) each.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives the next person on the same browser a session of their own, and ends the last one', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const project = await newProject();
|
||||||
|
const theirs = await signIn(server, project, { login: 'mia' });
|
||||||
|
const mine = await exchange(server, project, { login: 'ben', cookie: theirs });
|
||||||
|
expect(mine.headers.get('set-cookie')!.split(';')[0]).not.toBe(theirs);
|
||||||
|
await expect(join(server, project, { cookie: theirs })).rejects.toThrow('refused: notSignedIn');
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lengthens a session with every ticket and forgets the ones that ran out', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const project = await newProject();
|
||||||
|
const user = randomUUID();
|
||||||
|
const cookie = await signIn(server, project, { user });
|
||||||
|
await sql.query("UPDATE filius_session SET expires_at = now() + interval '1 minute' WHERE user_uuid = $1", [user]);
|
||||||
|
await exchange(server, project, { user, cookie });
|
||||||
|
const left = (await sql.query('SELECT extract(epoch FROM expires_at - now())::int AS s FROM filius_session WHERE user_uuid = $1', [user])).rows[0].s as number;
|
||||||
|
expect(left).toBeGreaterThan(SESSION_SECONDS - 60);
|
||||||
|
const gone = randomUUID();
|
||||||
|
await signIn(server, project, { user: gone });
|
||||||
|
await sql.query("UPDATE filius_session SET expires_at = now() - interval '1 second' WHERE user_uuid = $1", [gone]);
|
||||||
|
await signIn(server, project);
|
||||||
|
expect((await sql.query('SELECT count(*)::int AS n FROM filius_session WHERE user_uuid = $1', [gone])).rows[0].n).toBe(0);
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the way into a shared document', () => {
|
||||||
|
it('refuses without a session, with a session that ran out, and without a grant to the project — saying which', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const [project, other] = [await newProject(), await newProject()];
|
||||||
|
await expect(join(server, project)).rejects.toThrow('refused: notSignedIn');
|
||||||
|
await expect(join(server, project, { cookie: `${SESSION_COOKIE}=made-up` })).rejects.toThrow('refused: notSignedIn');
|
||||||
|
const user = randomUUID();
|
||||||
|
const cookie = await signIn(server, other, { user, login: 'ab12' });
|
||||||
|
await expect(join(server, project, { cookie })).rejects.toThrow('refused: noAccess');
|
||||||
|
await expect(join(server, 'not-a-project', { cookie })).rejects.toThrow('refused: notAProject');
|
||||||
|
await sql.query("UPDATE filius_session SET expires_at = now() - interval '1 second' WHERE user_uuid = $1", [user]);
|
||||||
|
await expect(join(server, other, { cookie })).rejects.toThrow('refused: notSignedIn');
|
||||||
|
expect(server.log.lines.filter((line) => line.event === 'sync.refused').map(({ reason, user: login }) => [reason, login])).toEqual([
|
||||||
|
['notSignedIn', undefined],
|
||||||
|
['notSignedIn', undefined],
|
||||||
|
['noAccess', 'ab12'],
|
||||||
|
['notSignedIn', undefined],
|
||||||
|
]);
|
||||||
|
expect(SYNC_REFUSALS).toEqual(['notAProject', 'notSignedIn', 'noAccess', 'unknownProject']);
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs who joined and whether they read', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const project = await newProject();
|
||||||
|
const person = await enter(server, project, { login: 'ab12', level: 'read' });
|
||||||
|
expect(server.log.lines.find((line) => line.event === 'sync.joined')).toEqual({ level: 'info', event: 'sync.joined', project, user: 'ab12', readOnly: true });
|
||||||
|
person.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a WebSocket from a page of another origin, and takes the editor's and one without an origin", async () => {
|
||||||
|
const server = await start();
|
||||||
|
expect(await upgrade(server, { origin: 'https://elsewhere.test' })).toBe(403);
|
||||||
|
expect(await upgrade(server, { origin: EDITOR_ORIGIN })).toBe(101);
|
||||||
|
expect(await upgrade(server, {})).toBe(101);
|
||||||
|
expect(server.log.lines.filter((line) => line.event === 'sync.foreignOrigin').map((line) => line.origin)).toEqual(['https://elsewhere.test']);
|
||||||
|
const project = await newProject();
|
||||||
|
const person = await enter(server, project, {}, { origin: EDITOR_ORIGIN });
|
||||||
|
expect(person.provider.isSynced).toBe(true);
|
||||||
|
person.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a cookie among others, and nothing that only looks like it', () => {
|
||||||
|
expect(cookieValue(`a=1; ${SESSION_COOKIE}=key; b=2`, SESSION_COOKIE)).toBe('key');
|
||||||
|
expect(cookieValue(`${SESSION_COOKIE}=key=with=equals`, SESSION_COOKIE)).toBe('key=with=equals');
|
||||||
|
expect(cookieValue(`x${SESSION_COOKIE}=key; =y`, SESSION_COOKIE)).toBeUndefined();
|
||||||
|
expect(cookieValue(undefined, SESSION_COOKIE)).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the dashboard's service", () => {
|
||||||
|
it('lets in only with the service key', async () => {
|
||||||
|
const server = await start();
|
||||||
|
for (const key of [null, '', 'wrong', `${SERVICE_KEY}x`]) expect(await answer(await service(server, 'GET', '/svc/sessions', undefined, key))).toEqual([403, { error: 'forbidden' }]);
|
||||||
|
expect(server.log.lines.filter((line) => line.event === 'svc.forbidden')).toHaveLength(4);
|
||||||
|
expect([serviceKeyMatches('k', 'k'), serviceKeyMatches('k', ''), serviceKeyMatches('', ''), serviceKeyMatches(['k'], 'k')]).toEqual([true, false, false, false]);
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('makes an empty project once', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const project = randomUUID();
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/projects', { project }))).toEqual([201, { project }]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/projects', { project }))).toEqual([409, { error: 'exists' }]);
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
Y.applyUpdate(doc, (await store.load(project))!.state);
|
||||||
|
expect(readProject(doc)).toEqual(emptyProject());
|
||||||
|
const person = await enter(server, project);
|
||||||
|
expect(deviceCount(person.doc)).toBe(0);
|
||||||
|
person.close();
|
||||||
|
expect(server.log.lines.filter((line) => line.event === 'svc.created').map((line) => line.project)).toEqual([project]);
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers what it cannot read, what it does not know and what is too large', async () => {
|
||||||
|
const server = await start();
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/projects', { project: 'x' }))).toEqual([400, { error: 'notAProject' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/projects', '{"project":'))).toEqual([400, { error: 'notJson' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/projects', '[1]'))).toEqual([400, { error: 'notAnObject' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/projects', 'null'))).toEqual([400, { error: 'notAnObject' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/projects', { project: 'x'.repeat(SERVICE_BODY_BYTES) }))).toEqual([413, { error: 'tooLarge' }]);
|
||||||
|
expect(await answer(await service(server, 'GET', '/svc/projects'))).toEqual([404, { error: 'notFound' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/sessions', {}))).toEqual([404, { error: 'notFound' }]);
|
||||||
|
expect(await answer(await service(server, 'PUT', `/svc/projects/${randomUUID()}`))).toEqual([404, { error: 'notFound' }]);
|
||||||
|
expect(await answer(await service(server, 'DELETE', '/svc/projects/x'))).toEqual([400, { error: 'notAProject' }]);
|
||||||
|
expect(await answer(await service(server, 'DELETE', `/svc/projects/${randomUUID()}`))).toEqual([404, { error: 'unknownProject' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', `/svc/projects/${randomUUID()}/freeze`, { frozen: true }))).toEqual([404, { error: 'unknownProject' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', `/svc/projects/${randomUUID()}/freeze`, { frozen: 'yes' }))).toEqual([400, { error: 'frozenNotABoolean' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/projects/x/freeze', { frozen: true }))).toEqual([400, { error: 'notAProject' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/revoke', { user: 'x' }))).toEqual([400, { error: 'notAPerson' }]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/revoke', { user: randomUUID(), project: 'x' }))).toEqual([400, { error: 'notAProject' }]);
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes a project: whoever is in it is let go and finds no project when coming back, nothing stored after', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const project = await newProject();
|
||||||
|
const cookie = await signIn(server, project);
|
||||||
|
const person = await join(server, project, { cookie });
|
||||||
|
place(person.doc, 'a');
|
||||||
|
await until(async () => (await store.pending(project)) === 1);
|
||||||
|
expect(await answer(await service(server, 'DELETE', `/svc/projects/${project}`))).toEqual([200, { deleted: true, disconnected: 1 }]);
|
||||||
|
await until(() => person.closings.length === 1);
|
||||||
|
expect(person.closings).toEqual(['deleted']);
|
||||||
|
await expect(join(server, project, { cookie })).rejects.toThrow('refused: unknownProject');
|
||||||
|
// What the person does now is stored nowhere.
|
||||||
|
place(person.doc, 'b');
|
||||||
|
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||||
|
expect(await store.load(project)).toBeUndefined();
|
||||||
|
expect(server.log.lines.filter((line) => ['store.failed', 'doc.unloadFailed'].includes(line.event as string))).toEqual([]);
|
||||||
|
expect(server.log.lines.find((line) => line.event === 'svc.deleted')).toMatchObject({ project, disconnected: 1 });
|
||||||
|
expect(await answer(await service(server, 'DELETE', `/svc/projects/${project}`))).toEqual([404, { error: 'unknownProject' }]);
|
||||||
|
expect(SYNC_CLOSINGS).toEqual(['revoked', 'deleted']);
|
||||||
|
person.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('freezes a project: whoever writes in it reads from then on and is told, and writes again when it is thawed', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const project = await newProject();
|
||||||
|
const writer = await enter(server, project, { level: 'write' });
|
||||||
|
const reader = await enter(server, project, { level: 'read' });
|
||||||
|
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: true }))).toEqual([200, { frozen: true }]);
|
||||||
|
await until(() => writer.messages.length === 1);
|
||||||
|
expect(writer.messages).toEqual([{ v: 1, kind: 'access', readOnly: true, frozen: true }]);
|
||||||
|
// Whoever comes now reads too, with a write grant; what they change is not taken.
|
||||||
|
const late = await enter(server, project, { level: 'write' });
|
||||||
|
expect(late.provider.authorizedScope).toBe('readonly');
|
||||||
|
place(late.doc, 'a');
|
||||||
|
await sleep(300);
|
||||||
|
expect(await store.pending(project)).toBe(0);
|
||||||
|
expect(deviceCount(writer.doc)).toBe(0);
|
||||||
|
late.close();
|
||||||
|
expect(await answer(await service(server, 'POST', `/svc/projects/${project}/freeze`, { frozen: false }))).toEqual([200, { frozen: false }]);
|
||||||
|
await until(() => writer.messages.length === 2);
|
||||||
|
expect(writer.messages[1]).toEqual({ v: 1, kind: 'access', readOnly: false, frozen: false });
|
||||||
|
place(writer.doc, 'b');
|
||||||
|
await until(async () => (await store.pending(project)) === 1);
|
||||||
|
await until(() => deviceCount(reader.doc) === 1);
|
||||||
|
// The reader was told nothing: it read before and reads still.
|
||||||
|
expect(reader.messages).toEqual([]);
|
||||||
|
expect(server.log.lines.filter((line) => line.event === 'svc.frozen').map((line) => line.frozen)).toEqual([true, false]);
|
||||||
|
writer.close();
|
||||||
|
reader.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ends a person's grant to one project: their connection there is let go and refused, the others stay", async () => {
|
||||||
|
const server = await start();
|
||||||
|
const [project, other] = [await newProject(), await newProject()];
|
||||||
|
const user = randomUUID();
|
||||||
|
const cookie = await signIn(server, project, { user });
|
||||||
|
await exchange(server, other, { user, cookie });
|
||||||
|
const here = await join(server, project, { cookie });
|
||||||
|
const there = await join(server, other, { cookie });
|
||||||
|
const someone = await enter(server, project);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/revoke', { user, project }))).toEqual([200, { revoked: 1, disconnected: 1 }]);
|
||||||
|
await until(() => here.closings.length === 1);
|
||||||
|
expect(here.closings).toEqual(['revoked']);
|
||||||
|
await expect(join(server, project, { cookie })).rejects.toThrow('refused: noAccess');
|
||||||
|
expect([there.closings, someone.closings]).toEqual([[], []]);
|
||||||
|
place(there.doc, 'a');
|
||||||
|
await until(async () => (await store.pending(other)) === 1);
|
||||||
|
expect(server.log.lines.find((line) => line.event === 'svc.revoked')).toMatchObject({ user, project, revoked: 1, disconnected: 1 });
|
||||||
|
for (const each of [here, there, someone]) each.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ends all sessions of a person: every connection of theirs is let go, and they are signed out', async () => {
|
||||||
|
const server = await start();
|
||||||
|
const [project, other] = [await newProject(), await newProject()];
|
||||||
|
const user = randomUUID();
|
||||||
|
const phone = await signIn(server, project, { user });
|
||||||
|
const laptop = await signIn(server, other, { user });
|
||||||
|
const a = await join(server, project, { cookie: phone });
|
||||||
|
const b = await join(server, other, { cookie: laptop });
|
||||||
|
const someone = await enter(server, project);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/revoke', { user }))).toEqual([200, { revoked: 2, disconnected: 2 }]);
|
||||||
|
await until(() => a.closings.length === 1 && b.closings.length === 1);
|
||||||
|
expect([a.closings, b.closings]).toEqual([['revoked'], ['revoked']]);
|
||||||
|
await expect(join(server, project, { cookie: phone })).rejects.toThrow('refused: notSignedIn');
|
||||||
|
await expect(join(server, other, { cookie: laptop })).rejects.toThrow('refused: notSignedIn');
|
||||||
|
expect(someone.closings).toEqual([]);
|
||||||
|
expect(await answer(await service(server, 'POST', '/svc/revoke', { user }))).toEqual([200, { revoked: 0, disconnected: 0 }]);
|
||||||
|
expect(server.log.lines.filter((line) => line.event === 'svc.revoked').at(-1)).not.toHaveProperty('project');
|
||||||
|
for (const each of [a, b, someone]) each.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists who is signed in with which grant, the stronger of two devices', async () => {
|
||||||
|
await sql.query('DELETE FROM filius_session');
|
||||||
|
const server = await start();
|
||||||
|
const [project, other] = [await newProject(), await newProject()];
|
||||||
|
const [mia, ben] = [randomUUID(), randomUUID()].sort() as [string, string];
|
||||||
|
await signIn(server, project, { user: mia, level: 'read' });
|
||||||
|
await signIn(server, project, { user: mia, level: 'write' });
|
||||||
|
await signIn(server, other, { user: ben, level: 'read' });
|
||||||
|
const gone = randomUUID();
|
||||||
|
await signIn(server, other, { user: gone });
|
||||||
|
await sql.query("UPDATE filius_session SET expires_at = now() - interval '1 second' WHERE user_uuid = $1", [gone]);
|
||||||
|
const grants = [
|
||||||
|
{ user: mia, project, level: 'write' },
|
||||||
|
{ user: ben, project: other, level: 'read' },
|
||||||
|
].sort((x, y) => (x.user + x.project).localeCompare(y.user + y.project));
|
||||||
|
expect(await answer(await service(server, 'GET', '/svc/sessions'))).toEqual([200, { users: [mia, ben].sort(), grants }]);
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('answers 500 when something breaks, and logs it', async () => {
|
||||||
|
const broken = Object.assign(Object.create(sessions) as PostgresSessions, {
|
||||||
|
active: async () => {
|
||||||
|
throw new Error('database gone');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const server = await start({ sessions: broken });
|
||||||
|
expect(await answer(await service(server, 'GET', '/svc/sessions'))).toEqual([500, { error: 'internal' }]);
|
||||||
|
expect(server.log.lines.find((line) => line.event === 'http.failed')).toMatchObject({ level: 'error', path: '/svc/sessions', error: 'database gone' });
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -8,23 +8,26 @@
|
|||||||
// answers, and the log is JSON.
|
// answers, and the log is JSON.
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider';
|
|
||||||
import pg from 'pg';
|
import pg from 'pg';
|
||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import WebSocket from 'ws';
|
import WebSocket from 'ws';
|
||||||
import * as Y from 'yjs';
|
import * as Y from 'yjs';
|
||||||
import { addDevice, connect, emptyProject, readProject, ROOTS, topologyView, writeProject } from '@mdg-filius/doc';
|
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 { testDatabase, type TestDatabase } from './database.js';
|
||||||
|
import { enter, newProject as makeProject, startTestServer, until, type TestServer } from './harness.js';
|
||||||
|
|
||||||
let database: TestDatabase;
|
let database: TestDatabase;
|
||||||
let store: PostgresStore;
|
let store: PostgresStore;
|
||||||
|
let sessions: PostgresSessions;
|
||||||
let sql: pg.Pool;
|
let sql: pg.Pool;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
database = await testDatabase();
|
database = await testDatabase();
|
||||||
store = PostgresStore.connect(database.url);
|
store = PostgresStore.connect(database.url);
|
||||||
|
sessions = new PostgresSessions(store.pool);
|
||||||
await store.migrate();
|
await store.migrate();
|
||||||
|
await sessions.migrate();
|
||||||
sql = new pg.Pool({ connectionString: database.url });
|
sql = new pg.Pool({ connectionString: database.url });
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
@@ -34,82 +37,12 @@ afterAll(async () => {
|
|||||||
await database?.stop();
|
await database?.stop();
|
||||||
});
|
});
|
||||||
|
|
||||||
/** The log's lines, kept. */
|
const start = (options: { readonly idle?: number; readonly store?: DocStore } = {}): Promise<TestServer> =>
|
||||||
function memoryLog(): Log & { readonly lines: Record<string, unknown>[] } {
|
startTestServer(options.store ?? store, sessions, options.idle === undefined ? {} : { compactAfterIdleMilliseconds: options.idle });
|
||||||
const lines: Record<string, unknown>[] = [];
|
const newProject = () => makeProject(store);
|
||||||
const entry =
|
/** A person signed in to the project with a write grant (a reader with `read`), in it. */
|
||||||
(level: string) =>
|
const join = (server: TestServer, project: string, eager = false, level: 'read' | 'write' = 'write') => enter(server, project, { level }, { eager });
|
||||||
(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') };
|
|
||||||
}
|
|
||||||
|
|
||||||
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 deviceCount = (doc: Y.Doc) => doc.getMap(ROOTS.nodes).size;
|
||||||
const NAMES = { pc: 'Rechner', networkSwitch: 'Switch' } as const;
|
const NAMES = { pc: 'Rechner', networkSwitch: 'Switch' } as const;
|
||||||
/** A device as the palette puts one down. */
|
/** 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);
|
await until(async () => (await store.pending(project)) >= 2);
|
||||||
first.close();
|
first.close();
|
||||||
// The last person left: compacted and unloaded.
|
// 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);
|
expect(await store.pending(project)).toBe(0);
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|
||||||
@@ -186,7 +119,7 @@ describe('server F keeps the shared documents', () => {
|
|||||||
const person = await join(server, project);
|
const person = await join(server, project);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
person.close();
|
person.close();
|
||||||
await until(() => server.sync.getDocumentsCount() === 0);
|
await until(() => server.sync.hocuspocus.getDocumentsCount() === 0);
|
||||||
expect(await rev()).toBe(0);
|
expect(await rev()).toBe(0);
|
||||||
expect(server.log.lines.some((line) => line.event === 'doc.compacted')).toBe(false);
|
expect(server.log.lines.some((line) => line.event === 'doc.compacted')).toBe(false);
|
||||||
await server.close();
|
await server.close();
|
||||||
@@ -214,7 +147,7 @@ describe('server F keeps the shared documents', () => {
|
|||||||
await until(async () => (await store.pending(project)) === 1);
|
await until(async () => (await store.pending(project)) === 1);
|
||||||
await store.remove(project);
|
await store.remove(project);
|
||||||
person.close();
|
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(server.log.lines.filter((line) => line.event === 'store.failed').map((line) => line.project)).toEqual([project]);
|
||||||
expect(await store.load(project)).toBeUndefined();
|
expect(await store.load(project)).toBeUndefined();
|
||||||
await server.close();
|
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]);
|
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 () => {
|
it('refuses a project its store does not have when it comes to load it, and makes none', async () => {
|
||||||
const server = await start();
|
// Deleted between the sign-in and the loading: the store has it no more.
|
||||||
const project = randomUUID();
|
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');
|
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.log.lines.some((line) => line.event === 'doc.unknown' && line.project === project)).toBe(true);
|
||||||
|
expect(server.sync.hocuspocus.getDocumentsCount()).toBe(0);
|
||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -330,8 +264,8 @@ describe('server F keeps the shared documents', () => {
|
|||||||
await until(async () => (await store.pending(project)) >= 1);
|
await until(async () => (await store.pending(project)) >= 1);
|
||||||
writer.close();
|
writer.close();
|
||||||
await writerServer.close();
|
await writerServer.close();
|
||||||
const server = await start({ access: { readOnly: true } });
|
const server = await start();
|
||||||
const reader = await join(server, project);
|
const reader = await join(server, project, false, 'read');
|
||||||
expect(deviceCount(reader.doc)).toBe(1);
|
expect(deviceCount(reader.doc)).toBe(1);
|
||||||
place(reader.doc, 'switch', 'networkSwitch', 0, 'reader');
|
place(reader.doc, 'switch', 'networkSwitch', 0, 'reader');
|
||||||
await new Promise((resolve) => setTimeout(resolve, 400));
|
await new Promise((resolve) => setTimeout(resolve, 400));
|
||||||
@@ -356,5 +290,6 @@ describe('server F keeps the shared documents', () => {
|
|||||||
expect(refused).toBe(404);
|
expect(refused).toBe(404);
|
||||||
await server.close();
|
await server.close();
|
||||||
expect(server.log.lines.map((line) => line.event)).toEqual(['server.listening', 'server.closed']);
|
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 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
// The entry ticket checked in Node as itsdangerous checks it in Python
|
||||||
|
// (tools/ticket-oracle → tickets.python.json): every ticket the oracle made
|
||||||
|
// reads the same — the same payload, or refused where Python refuses it, for
|
||||||
|
// the same reason (a signature that does not match, an age beyond 60 s or
|
||||||
|
// below 0) —; tickets signed in Node carry the same JSON and the same moment
|
||||||
|
// as Python's (packed by Node's zlib, Chromium's, the bytes differ; an
|
||||||
|
// unpacked one is the same to the byte); and
|
||||||
|
// the claims the server takes from a ticket: a person, a login, a project
|
||||||
|
// and a level, or nothing.
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { inflateSync } from 'node:zlib';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readSigned, signature, TICKET_SALT, TICKET_SECONDS, TicketError, verifyTicket } from '../src/ticket.js';
|
||||||
|
import { signTicket } from './tickets.js';
|
||||||
|
|
||||||
|
interface OracleCase {
|
||||||
|
readonly name: string;
|
||||||
|
readonly ticket: string;
|
||||||
|
readonly now: number;
|
||||||
|
readonly payload?: unknown;
|
||||||
|
readonly error?: 'expired' | 'badSignature';
|
||||||
|
}
|
||||||
|
|
||||||
|
const oracle = JSON.parse(readFileSync(new URL('./tickets.python.json', import.meta.url), 'utf8')) as {
|
||||||
|
readonly secret: string;
|
||||||
|
readonly salt: string;
|
||||||
|
readonly ttl: number;
|
||||||
|
readonly signedAt: number;
|
||||||
|
readonly cases: readonly OracleCase[];
|
||||||
|
};
|
||||||
|
/** A ticket's JSON as signed, unpacked. */
|
||||||
|
const unpacked = (ticket: string) => {
|
||||||
|
const body = ticket.split('.').at(-3)!;
|
||||||
|
const bytes = Buffer.from(body, 'base64url');
|
||||||
|
return (ticket.startsWith('.') ? inflateSync(bytes) : bytes).toString('utf8');
|
||||||
|
};
|
||||||
|
const byName = (name: string) => oracle.cases.find((each) => each.name === name)!;
|
||||||
|
|
||||||
|
function outcome(ticket: string, now: number): { payload: unknown } | { error: string } {
|
||||||
|
try {
|
||||||
|
return { payload: readSigned(ticket, oracle.secret, now) };
|
||||||
|
} catch (error) {
|
||||||
|
expect(error).toBeInstanceOf(TicketError);
|
||||||
|
const message = (error as Error).message;
|
||||||
|
return { error: /old|future/.test(message) ? 'expired' : 'badSignature' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('the ticket as itsdangerous signs it', () => {
|
||||||
|
it('shares the salt and the lifetime with the dashboard', () => {
|
||||||
|
expect([TICKET_SALT, TICKET_SECONDS]).toEqual([oracle.salt, oracle.ttl]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(oracle.cases.map((each) => [each.name, each] as const))('reads „%s“ as Python does', (_name, each) => {
|
||||||
|
expect(outcome(each.ticket, each.now)).toEqual(each.error === undefined ? { payload: each.payload } : { error: each.error });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(oracle.cases.filter((each) => each.error === undefined).map((each) => [each.name, each] as const))('signs „%s“ with the same JSON at the same moment', (_name, each) => {
|
||||||
|
const ours = signTicket(each.payload, oracle.secret, oracle.signedAt);
|
||||||
|
const [body, stamp] = ours.split('.').slice(-3);
|
||||||
|
const [theirBody, theirStamp] = each.ticket.split('.').slice(-3);
|
||||||
|
expect(unpacked(ours)).toBe(unpacked(each.ticket));
|
||||||
|
expect([ours.startsWith('.'), stamp]).toEqual([each.ticket.startsWith('.'), theirStamp]);
|
||||||
|
if (!ours.startsWith('.')) expect(body).toBe(theirBody);
|
||||||
|
expect(readSigned(ours, oracle.secret, oracle.signedAt)).toEqual(each.payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('covers a packed and an unpacked payload', () => {
|
||||||
|
expect(byName('plain').ticket.startsWith('.')).toBe(true);
|
||||||
|
expect(byName('listPayload').ticket.startsWith('.')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses without a secret, whatever the ticket', () => {
|
||||||
|
expect(() => readSigned(signTicket({ a: 1 }, '', oracle.signedAt), '', oracle.signedAt)).toThrow('no secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a signed value without a moment of signing', () => {
|
||||||
|
const value = Buffer.from('{}').toString('base64url');
|
||||||
|
expect(() => readSigned(`${value}.${signature(value, oracle.secret)}`, oracle.secret, oracle.signedAt)).toThrow('no timestamp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a signed payload that is not packed JSON', () => {
|
||||||
|
const value = `${Buffer.from('{"u":').toString('base64url')}.${Buffer.from([1]).toString('base64url')}`;
|
||||||
|
const forged = signTicket({}, oracle.secret, 1).replace(/^[^.]*\.[^.]*/, value);
|
||||||
|
// Re-signed after the change, so that only the payload is wrong.
|
||||||
|
const signed = `${value}.${forged.split('.').at(-1)}`;
|
||||||
|
expect(() => readSigned(signed, oracle.secret, 1)).toThrow();
|
||||||
|
expect(() => readSigned(`.${Buffer.from('no zlib').toString('base64url')}.AQ.x`, oracle.secret, 1)).toThrow(TicketError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the claims of a ticket', () => {
|
||||||
|
const claims = (name: string) => verifyTicket(byName(name).ticket, oracle.secret, byName(name).now);
|
||||||
|
|
||||||
|
it('names the person, the project and the level', () => {
|
||||||
|
expect(claims('plain')).toEqual({
|
||||||
|
user: '0b8f5f2e-1a3c-4d5e-8f90-123456789abc',
|
||||||
|
login: 'ab12',
|
||||||
|
name: 'Ada Beispiel',
|
||||||
|
color: '#3366cc',
|
||||||
|
language: 'de',
|
||||||
|
project: '6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6',
|
||||||
|
level: 'write',
|
||||||
|
});
|
||||||
|
expect(claims('readLevel').level).toBe('read');
|
||||||
|
expect(claims('umlautsAndEmoji')).toMatchObject({ name: 'Jörg Ömer Weiß 🐧', language: 'en' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills the name, the colour and the language in when they are missing or empty', () => {
|
||||||
|
expect(claims('onlyRequired')).toMatchObject({ login: 'ab12', name: 'ab12', color: '', language: 'de', level: 'read' });
|
||||||
|
const at = oracle.signedAt;
|
||||||
|
const empty = { u: '0b8f5f2e-1a3c-4d5e-8f90-123456789abc', n: 'ab12', d: '', c: '', lang: '', p: '6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6', lvl: 'read' };
|
||||||
|
expect(verifyTicket(signTicket(empty, 's', at), 's', at)).toMatchObject({ name: 'ab12', color: '', language: 'de' });
|
||||||
|
expect(() => verifyTicket(signTicket({ ...empty, n: '' }, 's', at), 's', at)).toThrow('no login');
|
||||||
|
expect(() => verifyTicket(signTicket({ ...empty, n: 7 }, 's', at), 's', at)).toThrow('no login');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['listPayload', 'payload is not an object'],
|
||||||
|
['withoutLevel', 'no level'],
|
||||||
|
['unknownLevel', 'no level'],
|
||||||
|
['projectNotAUuid', 'no project'],
|
||||||
|
['withoutName', 'no login'],
|
||||||
|
])('refuses „%s“ (%s)', (name, reason) => {
|
||||||
|
expect(() => claims(name)).toThrow(reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a person or a project that is not a UUID or only holds one, and null', () => {
|
||||||
|
const at = oracle.signedAt;
|
||||||
|
const person = '0b8f5f2e-1a3c-4d5e-8f90-123456789abc';
|
||||||
|
const project = '6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6';
|
||||||
|
const read = (fields: object) => verifyTicket(signTicket({ u: person, n: 'ab12', p: project, lvl: 'read', ...fields }, 's', at), 's', at);
|
||||||
|
expect(() => read({ u: 'ab12' })).toThrow('no person');
|
||||||
|
expect(() => read({ u: `x${person}` })).toThrow('no person');
|
||||||
|
expect(() => read({ u: `${person}x` })).toThrow('no person');
|
||||||
|
expect(() => read({ p: `../${project}` })).toThrow('no project');
|
||||||
|
expect(() => read({ u: person.toUpperCase() })).toThrow('no person');
|
||||||
|
expect(() => verifyTicket(signTicket(null, 's', at), 's', at)).toThrow('payload is not an object');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts the age from now by default', () => {
|
||||||
|
const ticket = signTicket({ u: '0b8f5f2e-1a3c-4d5e-8f90-123456789abc', n: 'ab12', p: '6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6', lvl: 'write' }, 's');
|
||||||
|
expect(verifyTicket(ticket, 's').level).toBe('write');
|
||||||
|
expect(() => verifyTicket(byName('plain').ticket, oracle.secret)).toThrow(TicketError);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
{
|
||||||
|
"itsdangerous": "2.2.0",
|
||||||
|
"python": "3.13.5",
|
||||||
|
"secret": "filius-test-secret",
|
||||||
|
"salt": "mdg-filius-ticket",
|
||||||
|
"ttl": 60,
|
||||||
|
"signedAt": 1790000000,
|
||||||
|
"cases": [
|
||||||
|
{
|
||||||
|
"name": "plain",
|
||||||
|
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"n": "ab12",
|
||||||
|
"d": "Ada Beispiel",
|
||||||
|
"c": "#3366cc",
|
||||||
|
"lang": "de",
|
||||||
|
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||||
|
"lvl": "write"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "readLevel",
|
||||||
|
"ticket": ".eJwVi0kKwkAQAL8i7dWGzJoZb_qT3kYCIQRFL-Lf7dyKouoLb7jCxG2UEQ0DJcGsxbCNPmGIKZc6t04scIHNU-IQHdXxpnS62_LaF1tdiatzSrXK0a60PVyoOe8OdQSNkhgzlY7ZmmKXmZECu9ZsZdRj-6weP40Ufn9uhyly.arE7gA.CA_tdrFV7T4m5P4ZTVcLpUTBby8",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"n": "ab12",
|
||||||
|
"d": "Ada Beispiel",
|
||||||
|
"c": "#3366cc",
|
||||||
|
"lang": "de",
|
||||||
|
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||||
|
"lvl": "read"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "umlautsAndEmoji",
|
||||||
|
"ticket": ".eJwVi7sNwjAURVexTMuT4m9sRmABan-eo0jBQlGAArEDFW1KNmABL8QIvHRH99zz4Fd-4F10xRSJIIJKoLNBcMV3IKTSxvbOh5j4nle6higkYSY8tu88sPY-48xOOLaV_dbXh2QiuVPK2rRVU6gDDViJLwS2iCyTiqCD8aDRZfCpjxBEpDlrNMVu2W2i830eF-TPP73wMJ4.arE7gA.iCZHG4Ent52jQ4US5NkTWZ-62pw",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"n": "ab12",
|
||||||
|
"d": "Jörg Ömer Weiß 🐧",
|
||||||
|
"c": "#3366cc",
|
||||||
|
"lang": "en",
|
||||||
|
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||||
|
"lvl": "write"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "compressed",
|
||||||
|
"ticket": ".eJxVzstqwzAQBdBfEeo2I2LrETvLdp1Vv0CPUS2QZWNbSUnIv2cSKCWLgctw5zA3XvmR710XdWwRGis9qKARutjvoWml0ubQ9dZ5vuOFqqP9TWPKyRYU6zQPCYUf7JKnbUNxnoqoJYhrFQ7TOifMg60rFhjruuFCkwpBgaDTP8S-XxD7-oMYQYwgdq3s8x06vUGeoA8pjfHP_7ItP7QISHmmYGITWi8dKKt7UNgF6P3BgW0crYNCHc3z7JypfFnShvz-ALoRWAg.arE7gA.FCNmtMClRGk5V70ufHkfaOaYXuY",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"n": "maximiliane.sophie.charlotte.von.und.zu.beispielhausen-musterstein",
|
||||||
|
"d": "Maximiliane Sophie Charlotte von und zu Beispielhausen-Musterstein",
|
||||||
|
"c": "#3366cc",
|
||||||
|
"lang": "de",
|
||||||
|
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||||
|
"lvl": "write"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sixtySecondsOld",
|
||||||
|
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||||
|
"now": 1790000060,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"n": "ab12",
|
||||||
|
"d": "Ada Beispiel",
|
||||||
|
"c": "#3366cc",
|
||||||
|
"lang": "de",
|
||||||
|
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||||
|
"lvl": "write"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "sixtyOneSecondsOld",
|
||||||
|
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||||
|
"now": 1790000061,
|
||||||
|
"error": "expired"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fromTheFuture",
|
||||||
|
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||||
|
"now": 1789999999,
|
||||||
|
"error": "expired"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "otherSecret",
|
||||||
|
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.4wnVSAZOvBXnfPb_aCCd2137AEI",
|
||||||
|
"now": 1790000000,
|
||||||
|
"error": "badSignature"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "scratchSalt",
|
||||||
|
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.n87SY_X1-cvBv6gZmgmGTgGlIcs",
|
||||||
|
"now": 1790000000,
|
||||||
|
"error": "badSignature"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "tamperedPayload",
|
||||||
|
"ticket": ".eAwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA.xwmcG5wEqkYKe3DxNMUxtEnXaQo",
|
||||||
|
"now": 1790000000,
|
||||||
|
"error": "badSignature"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "noSignature",
|
||||||
|
"ticket": ".eJwVi0kKwzAMAL9S1GsF8Rq7t_YnsiQXQwih66H071VuwzDzhRecYWqlp-4VHQXGKEmx9Dqh8yGmPJdKjeEEq6XUnDcUw4vQ4arjsQ1dTLGpYwg5894utN5MiBpvBrk78RwaRkoVoxbBynNDcs20RE0979t7sfhzH0-F3x-ZYioB.arE7gA",
|
||||||
|
"now": 1790000000,
|
||||||
|
"error": "badSignature"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "garbage",
|
||||||
|
"ticket": "not a ticket",
|
||||||
|
"now": 1790000000,
|
||||||
|
"error": "badSignature"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "empty",
|
||||||
|
"ticket": "",
|
||||||
|
"now": 1790000000,
|
||||||
|
"error": "badSignature"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "listPayload",
|
||||||
|
"ticket": "WyJ1IiwicCJd.arE7gA.pXsWj5Myfb6h4fhFYYSxWDxcBxU",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": [
|
||||||
|
"u",
|
||||||
|
"p"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withoutLevel",
|
||||||
|
"ticket": ".eJwVykkKwkAQQNGrSLm1ID2m2525SU0tgRAC4irk7pa7z-Of8IUnTNxGGdEwUBLMWgzb6BOGmHKpc-vEAg_YfSUO0VM9X0q3xdbPsdrmJE73lGqV_7vR_nZQ8z486ggaJTFmKh2zNcUuMyMFdtZsZVS4fm3rJZo.arE7gA.gZ_wCIiMnGMbf1tD13qYm6XzNzI",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"n": "ab12",
|
||||||
|
"d": "Ada Beispiel",
|
||||||
|
"c": "#3366cc",
|
||||||
|
"lang": "de",
|
||||||
|
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "unknownLevel",
|
||||||
|
"ticket": ".eJwVjUsKAjEQBa8i7daGyXcSd3qTTrojgRgGRDfi3e3siqIe7wtvuMJWUgvNChpyFT0HwdTyhsY6H-KeMpUKF5iaUjFWkRVvTKe79NfRZaiqqs7OxVhXO2g-VLAoHwqxGbbVFfQUMnpJjLnuBckU1ewltLhmn7FO-Nkn_P6Ydynf.arE7gA.VhUG56J86GGt_eX0M_Aq-Cr38qk",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"n": "ab12",
|
||||||
|
"d": "Ada Beispiel",
|
||||||
|
"c": "#3366cc",
|
||||||
|
"lang": "de",
|
||||||
|
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||||
|
"lvl": "admin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "projectNotAUuid",
|
||||||
|
"ticket": ".eJwdikEOwiAURK9ivlupBQrS7vQmH_gYEtI0teqi8e6O7t68eTs9aaI-huKKEaXZJjVkJyqUsVfa2MH5Sxg5JjrRjJSjNsAMvGY-3KQ-lioNKkEdrfU-_drG8x0iC3gBdN1Ztv_xapjvtW5Cny86xCHC.arE7gA.2ayBJAFLfDUpzgXtbnEy8N95QXk",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"n": "ab12",
|
||||||
|
"d": "Ada Beispiel",
|
||||||
|
"c": "#3366cc",
|
||||||
|
"lang": "de",
|
||||||
|
"p": "../etc",
|
||||||
|
"lvl": "write"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "withoutName",
|
||||||
|
"ticket": ".eJwVi0sOwjAMBe9itlhqYsdNuE3sOAipQgjxWVTcHXc3bzRvhzdcYNE6y8yOqZMhj-JYZ1swZeIia21dDc5gkZ6IROxYW79fQwwPfgTITCMbKXIvDdnrwGarYk8aerCXKcfts0X8fd5eDr8_LEIh2g.arE7gA.hLO9zSKg8cblJgKFXxatwaRMJNI",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"c": "#3366cc",
|
||||||
|
"lang": "de",
|
||||||
|
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||||
|
"lvl": "write"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "onlyRequired",
|
||||||
|
"ticket": ".eJwNykEKAzEIQNG7uK4wJppJehuNuhpKKbSb0rvX3efzvvCGOxw2U7IFkvaN7BI4cx1IrbOMcy61DTd4FFWjVvmsHEnedjdklYUc03Ht01DJajuH5Ch6fa7Cr1CH3x9Dch1o.arE7gA.YCJxP6HbXDZ_cvikkPTvJIRfC7Q",
|
||||||
|
"now": 1790000000,
|
||||||
|
"payload": {
|
||||||
|
"u": "0b8f5f2e-1a3c-4d5e-8f90-123456789abc",
|
||||||
|
"n": "ab12",
|
||||||
|
"p": "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6",
|
||||||
|
"lvl": "read"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
// Tickets as the dashboard signs them, made in Node for the server's tests
|
||||||
|
// (the Python oracle shows the same JSON at the same moment: ticket.test.ts).
|
||||||
|
|
||||||
|
import { deflateSync } from 'node:zlib';
|
||||||
|
import { signature, TICKET_SALT } from '../src/ticket.js';
|
||||||
|
|
||||||
|
export function signTicket(payload: unknown, secret: string, at = Math.floor(Date.now() / 1000), salt = TICKET_SALT): string {
|
||||||
|
const json = Buffer.from(JSON.stringify(payload));
|
||||||
|
const packed = deflateSync(json);
|
||||||
|
const body = packed.length < json.length - 1 ? `.${packed.toString('base64url')}` : json.toString('base64url');
|
||||||
|
let stamp = Buffer.alloc(0);
|
||||||
|
for (let rest = at; rest > 0; rest = Math.floor(rest / 256)) stamp = Buffer.concat([Buffer.from([rest % 256]), stamp]);
|
||||||
|
const value = `${body}.${stamp.toString('base64url')}`;
|
||||||
|
return `${value}.${signature(value, secret, salt)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
.venv/
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Orakel für die Eintrittskarte
|
||||||
|
|
||||||
|
Das Dashboard signiert die Eintrittskarte für Server F mit itsdangerous
|
||||||
|
(`URLSafeTimedSerializer`, Salz `mdg-filius-ticket`, 60 s), Server F prüft
|
||||||
|
sie in Node (`packages/server/src/ticket.ts`). Dieses Orakel lässt
|
||||||
|
itsdangerous selbst Karten ausstellen — bei fester Uhr — und hält fest, was
|
||||||
|
es beim Lesen zu einem Zeitpunkt antwortet: die Nutzlast, abgelaufen oder
|
||||||
|
falsch signiert.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./run.sh # schreibt packages/server/test/tickets.python.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Braucht Python 3; itsdangerous kommt in der Version des Dashboards
|
||||||
|
(`backend/requirements.txt`) in ein eigenes `.venv`.
|
||||||
|
|
||||||
|
Fälle: gewöhnliche Karte (schon sie ist zlib-gepackt), Lesestufe, Umlaute
|
||||||
|
und Emoji, lange Namen, genau 60 s alt, 61 s alt, aus der Zukunft, fremdes
|
||||||
|
Geheimnis, Scratchs Salz, eine veränderte Nutzlast, ohne Signatur,
|
||||||
|
Unsinn, leer, eine Liste statt eines Objekts (ungepackt), Karten ohne
|
||||||
|
Stufe, mit unbekannter Stufe, mit einem Projekt, das keine UUID ist, ohne
|
||||||
|
Namen, nur mit den Pflichtfeldern.
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Writes packages/server/test/tickets.python.json: the dashboard's entry
|
||||||
|
# tickets as itsdangerous makes and reads them (plan §8.5). Needs Python 3;
|
||||||
|
# itsdangerous comes in the dashboard's version into a venv of its own.
|
||||||
|
set -eu
|
||||||
|
here="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
root="$(cd "$here/../.." && pwd)"
|
||||||
|
if [ ! -x "$here/.venv/bin/python" ]; then
|
||||||
|
python3 -m venv "$here/.venv"
|
||||||
|
"$here/.venv/bin/pip" install -q itsdangerous==2.2.0
|
||||||
|
fi
|
||||||
|
out="$root/packages/server/test/tickets.python.json"
|
||||||
|
# Removed first: a crashed run must not leave the previous result standing.
|
||||||
|
rm -f "$out"
|
||||||
|
"$here/.venv/bin/python" "$here/tickets.py" "$out"
|
||||||
|
test -s "$out"
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""The dashboard's entry tickets, made by itsdangerous itself (plan §8.5).
|
||||||
|
|
||||||
|
The dashboard signs a ticket with itsdangerous' URLSafeTimedSerializer (the
|
||||||
|
same as Scratch's), server F checks it in Node. This writes tickets as the
|
||||||
|
dashboard makes them, with a fixed clock, and what itsdangerous answers when
|
||||||
|
it reads each one back at a given moment: the payload, or which error.
|
||||||
|
packages/server/test/tickets.python.json holds the result; the server's
|
||||||
|
tests read every ticket with the Node check and expect the same answer.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import itsdangerous
|
||||||
|
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||||
|
from itsdangerous.timed import TimestampSigner
|
||||||
|
|
||||||
|
SECRET = "filius-test-secret"
|
||||||
|
SALT = "mdg-filius-ticket"
|
||||||
|
TTL = 60
|
||||||
|
SIGNED_AT = 1_790_000_000
|
||||||
|
|
||||||
|
clock = {"now": SIGNED_AT}
|
||||||
|
TimestampSigner.get_timestamp = lambda self: clock["now"]
|
||||||
|
|
||||||
|
PERSON = "0b8f5f2e-1a3c-4d5e-8f90-123456789abc"
|
||||||
|
PROJECT = "6f1d2c3b-4a59-4e8d-9c7b-a1b2c3d4e5f6"
|
||||||
|
|
||||||
|
|
||||||
|
def claims(**more):
|
||||||
|
base = {"u": PERSON, "n": "ab12", "d": "Ada Beispiel", "c": "#3366cc", "lang": "de", "p": PROJECT, "lvl": "write"}
|
||||||
|
base.update(more)
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def sign(payload, secret=SECRET, salt=SALT, at=SIGNED_AT):
|
||||||
|
clock["now"] = at
|
||||||
|
return URLSafeTimedSerializer(secret, salt=salt).dumps(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def answer(ticket, now):
|
||||||
|
clock["now"] = now
|
||||||
|
try:
|
||||||
|
return {"payload": URLSafeTimedSerializer(SECRET, salt=SALT).loads(ticket, max_age=TTL)}
|
||||||
|
except SignatureExpired:
|
||||||
|
return {"error": "expired"}
|
||||||
|
except BadSignature:
|
||||||
|
return {"error": "badSignature"}
|
||||||
|
|
||||||
|
|
||||||
|
def tampered(ticket):
|
||||||
|
# One letter of the payload changed (the first after its first two).
|
||||||
|
letter = ticket[2]
|
||||||
|
return ticket[:2] + ("A" if letter != "A" else "B") + ticket[3:]
|
||||||
|
|
||||||
|
|
||||||
|
def main(out):
|
||||||
|
plain = sign(claims())
|
||||||
|
long_name = "Maximiliane Sophie Charlotte von und zu Beispielhausen-Musterstein"
|
||||||
|
cases = [
|
||||||
|
("plain", plain, SIGNED_AT),
|
||||||
|
("readLevel", sign(claims(lvl="read")), SIGNED_AT),
|
||||||
|
("umlautsAndEmoji", sign(claims(d="Jörg Ömer Weiß 🐧", lang="en")), SIGNED_AT),
|
||||||
|
("compressed", sign(claims(d=long_name, n=long_name.lower().replace(" ", "."))), SIGNED_AT),
|
||||||
|
("sixtySecondsOld", plain, SIGNED_AT + 60),
|
||||||
|
("sixtyOneSecondsOld", plain, SIGNED_AT + 61),
|
||||||
|
("fromTheFuture", plain, SIGNED_AT - 1),
|
||||||
|
("otherSecret", sign(claims(), secret="another-secret"), SIGNED_AT),
|
||||||
|
("scratchSalt", sign(claims(), salt="mdg-scratch-ticket"), SIGNED_AT),
|
||||||
|
("tamperedPayload", tampered(plain), SIGNED_AT),
|
||||||
|
("noSignature", plain.rsplit(".", 1)[0], SIGNED_AT),
|
||||||
|
("garbage", "not a ticket", SIGNED_AT),
|
||||||
|
("empty", "", SIGNED_AT),
|
||||||
|
("listPayload", sign(["u", "p"]), SIGNED_AT),
|
||||||
|
("withoutLevel", sign({k: v for k, v in claims().items() if k != "lvl"}), SIGNED_AT),
|
||||||
|
("unknownLevel", sign(claims(lvl="admin")), SIGNED_AT),
|
||||||
|
("projectNotAUuid", sign(claims(p="../etc")), SIGNED_AT),
|
||||||
|
("withoutName", sign({k: v for k, v in claims().items() if k not in ("n", "d")}), SIGNED_AT),
|
||||||
|
("onlyRequired", sign({"u": PERSON, "n": "ab12", "p": PROJECT, "lvl": "read"}), SIGNED_AT),
|
||||||
|
]
|
||||||
|
result = {
|
||||||
|
"itsdangerous": __import__("importlib.metadata").metadata.version("itsdangerous"),
|
||||||
|
"python": sys.version.split()[0],
|
||||||
|
"secret": SECRET,
|
||||||
|
"salt": SALT,
|
||||||
|
"ttl": TTL,
|
||||||
|
"signedAt": SIGNED_AT,
|
||||||
|
"cases": [{"name": name, "ticket": ticket, "now": now, **answer(ticket, now)} for name, ticket, now in cases],
|
||||||
|
}
|
||||||
|
with open(out, "w", encoding="utf-8") as file:
|
||||||
|
json.dump(result, file, ensure_ascii=False, indent=2)
|
||||||
|
file.write("\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv[1])
|
||||||
Reference in New Issue
Block a user