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>
169 lines
7.4 KiB
TypeScript
169 lines
7.4 KiB
TypeScript
// 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 })) };
|
|
}
|
|
}
|