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:
@@ -1,19 +1,24 @@
|
||||
// Server F's front door (plan §8.2): one HTTP server — /healthz for the
|
||||
// proxy, the WebSocket /sync for the shared documents; anything else is not
|
||||
// found. Closed, it first takes no one new (a client lets go and comes back
|
||||
// at once, which would keep its document open), then lets everyone go,
|
||||
// waits until every document is stored and unloaded, and stops.
|
||||
// proxy, /__auth for the editor's frame (ticket in, cookie out), /svc/* for
|
||||
// the dashboard (with the service key), the WebSocket /sync for the shared
|
||||
// documents; anything else is not found. A WebSocket from a page of another
|
||||
// origin is refused (a page elsewhere under the same domain would bring the
|
||||
// person's cookie along: SameSite only tells sites apart). Closed, it first
|
||||
// takes no one new (a client lets go and comes back at once, which would
|
||||
// keep its document open), then lets everyone go, waits until every
|
||||
// document is stored and unloaded, and stops.
|
||||
|
||||
import { createServer, type Server } from 'node:http';
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
||||
import type { AddressInfo, Socket } from 'node:net';
|
||||
import type { Hocuspocus } from '@hocuspocus/server';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import type { Log } from './log.js';
|
||||
import { createSync, type SyncOptions } from './sync.js';
|
||||
import { exchangeTicket } from './auth.js';
|
||||
import { errorText, type Log } from './log.js';
|
||||
import { handleService } from './service.js';
|
||||
import { createSync, type Sync, type SyncOptions } from './sync.js';
|
||||
|
||||
export interface FiliusServer {
|
||||
readonly port: number;
|
||||
readonly sync: Hocuspocus;
|
||||
readonly sync: Sync;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -21,25 +26,40 @@ export interface ServerOptions extends SyncOptions {
|
||||
readonly port?: number;
|
||||
readonly host?: string;
|
||||
readonly log: Log;
|
||||
readonly ticketSecret: string;
|
||||
readonly serviceKey: string;
|
||||
/** The origins the editor is served from (FILIUS_ORIGINS): a WebSocket from another is refused. */
|
||||
readonly origins: readonly string[];
|
||||
readonly secureCookie: boolean;
|
||||
}
|
||||
|
||||
function refuse(socket: Socket, status: string): void {
|
||||
socket.end(`HTTP/1.1 ${status}\r\nConnection: close\r\n\r\n`);
|
||||
}
|
||||
|
||||
function json(response: ServerResponse, status: number, value: object): void {
|
||||
response.writeHead(status, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify(value));
|
||||
}
|
||||
|
||||
export async function startServer(options: ServerOptions): Promise<FiliusServer> {
|
||||
const sync = createSync(options);
|
||||
const { hocuspocus } = sync;
|
||||
const sockets = new WebSocketServer({ noServer: true });
|
||||
let closing = false;
|
||||
const handle = async (request: IncomingMessage, response: ServerResponse, path: string) => {
|
||||
if (request.method === 'GET' && path === '/healthz') json(response, 200, { status: 'ok', documents: hocuspocus.getDocumentsCount(), connections: hocuspocus.getConnectionsCount() });
|
||||
else if (request.method === 'GET' && path === '/__auth') await exchangeTicket(request, response, options);
|
||||
else if (path.startsWith('/svc/')) await handleService(request, response, path, { ...options, sync });
|
||||
else json(response, 404, { error: 'notFound' });
|
||||
};
|
||||
const http: Server = createServer((request, response) => {
|
||||
const path = new URL(request.url ?? '/', 'http://filius').pathname;
|
||||
if (request.method === 'GET' && path === '/healthz') {
|
||||
response.writeHead(200, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ status: 'ok', documents: sync.getDocumentsCount(), connections: sync.getConnectionsCount() }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, { 'content-type': 'application/json' });
|
||||
response.end(JSON.stringify({ error: 'notFound' }));
|
||||
handle(request, response, path).catch((error: unknown) => {
|
||||
options.log.error('http.failed', { path, error: errorText(error) });
|
||||
if (!response.headersSent) json(response, 500, { error: 'internal' });
|
||||
else response.end();
|
||||
});
|
||||
});
|
||||
http.on('upgrade', (request, socket, head) => {
|
||||
const path = new URL(request.url ?? '/', 'http://filius').pathname;
|
||||
@@ -47,11 +67,17 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
|
||||
refuse(socket as Socket, '404 Not Found');
|
||||
return;
|
||||
}
|
||||
const origin = request.headers.origin;
|
||||
if (origin !== undefined && !options.origins.includes(origin)) {
|
||||
options.log.warn('sync.foreignOrigin', { origin });
|
||||
refuse(socket as Socket, '403 Forbidden');
|
||||
return;
|
||||
}
|
||||
if (closing) {
|
||||
refuse(socket as Socket, '503 Service Unavailable');
|
||||
return;
|
||||
}
|
||||
sockets.handleUpgrade(request, socket, head, (ws) => sync.handleConnection(ws, request));
|
||||
sockets.handleUpgrade(request, socket, head, (ws) => hocuspocus.handleConnection(ws, request));
|
||||
});
|
||||
await new Promise<void>((resolve) => http.listen(options.port ?? 0, options.host ?? '0.0.0.0', resolve));
|
||||
const port = (http.address() as AddressInfo).port;
|
||||
@@ -61,14 +87,14 @@ export async function startServer(options: ServerOptions): Promise<FiliusServer>
|
||||
sync,
|
||||
async close() {
|
||||
closing = true;
|
||||
sync.closeConnections();
|
||||
hocuspocus.closeConnections();
|
||||
// Hocuspocus' close is a message on the socket, after which a client signs in again on it: the sockets go too.
|
||||
for (const socket of sockets.clients) socket.close(1001, 'service stopping');
|
||||
// Each document is stored and unloaded once its last person has gone.
|
||||
for (let waited = 0; sync.getDocumentsCount() > 0 && waited < 10_000; waited += 50) await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
for (let waited = 0; hocuspocus.getDocumentsCount() > 0 && waited < 10_000; waited += 50) await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
sockets.close();
|
||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
||||
options.log.info('server.closed', { documents: sync.getDocumentsCount() });
|
||||
options.log.info('server.closed', { documents: hocuspocus.getDocumentsCount() });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user