// 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; /** 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; /** A person's grant to a project ended, or (without a project) all their sessions; how many. */ revoke(user: string, project?: string): Promise; active(): Promise; } 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 { 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 { const result = await this.pool.query( `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 { 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 { 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 })) }; } }