pi-mono/apps/p2/plugins/sharing/db.ts
Mario Zechner 20d86c3657 feat(p2): Chord v2 cleanup pass
p2 rewritten by the Chord v2 authoring rules (decision "App authoring rules confirmed
by the p2 audit"), in four parts merged together:
- core: session lifecycle (relocate, clone, fork, archive) and search rebuild are
  persisted jobs on a new server store; Sessions v6 with client-chosen ids and visible
  operations; Retry answers with the worker's real outcome; workers push branch,
  subagent and status state to the server; roots take the method's ctx.
- services: models, envs, ports, login, mcp, logs, sharing and voice follow external
  state (self.follow, followNative) instead of copy loops; methods answer after their
  state commits; presence from host.references.
- chat: chat mode, artifacts and prompt resources follow Durable and native state;
  shell commands are Chord jobs (shellId is the requestId); status pushed before
  send and stop answer; race-free draft claim.
- ui: optimistic updates on web (useOptimistic) and terminal (TuiScope.optimistic,
  extended in Chord with a reducer and a pending flag), pending states for long work,
  visible rollback, load waterfalls removed; merged with the one composer and
  immediate first send (ccff9876e) and the folder + button (5a31a6d5f).
docs/optimistic-ui.md describes the pattern.
2026-11-05 18:22:13 +00:00

348 lines
13 KiB
TypeScript

// Share links, guests and their grants (SQLite). Session state (archived) is checked by the caller.
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { chmodSync } from "node:fs";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { ShareLink, ShareMode } from "./contract.ts";
/**
* Version 2 separates who a guest is (`guests`) from what was shared with them (`grants`, one per session), so that one
* guest can hold several sessions, and adds guests the owner shared with by email. Version 1 had one guest per link.
*/
const SCHEMA_VERSION = 2;
const MODES: readonly ShareMode[] = ["view", "full"];
const MAX_GUESTS_PER_LINK = 1000;
const hash = (secret: string): string => createHash("sha256").update(secret).digest("hex");
const GUESTS = `CREATE TABLE IF NOT EXISTS guests (
id TEXT PRIMARY KEY, name TEXT NOT NULL,
secretHash TEXT, email TEXT UNIQUE,
revision INTEGER NOT NULL DEFAULT 1, lastSeen INTEGER NOT NULL
) STRICT`;
const GRANTS = `CREATE TABLE IF NOT EXISTS grants (
guest TEXT NOT NULL, session TEXT NOT NULL, mode TEXT NOT NULL, linkId TEXT,
removed INTEGER NOT NULL DEFAULT 0, grantedAt INTEGER NOT NULL,
PRIMARY KEY (guest, session)
) STRICT`;
/**
* A guest: a browser that joined through links (`secretHash` of its cookie), or a person the owner shared with by
* email (`email`, normalized; Radius authenticates them). Never both.
*/
export type GuestRow = {
readonly id: string;
readonly name: string;
readonly email: string | null;
readonly revision: number;
};
/** What one guest may do in one session. `linkId` is the link it joined through; null when shared by email. */
export type GrantRow = {
readonly session: string;
readonly mode: ShareMode;
readonly linkId: string | null;
readonly removed: boolean;
readonly grantedAt: number;
};
/** A session shared with a person by email; `name` is their email as the owner wrote it. */
export type PersonRow = {
readonly id: string;
readonly name: string;
readonly session: string;
readonly mode: ShareMode;
};
/** A grant to a person by email that is not removed: the email (normalized) and the session. */
export type EmailGrant = { readonly email: string; readonly session: string };
/** The emails of `grants` on sessions `open` accepts, each once, sorted. */
export function openEmails(grants: readonly EmailGrant[], open: (session: string) => boolean): string[] {
return [...new Set(grants.filter((grant) => open(grant.session)).map((grant) => grant.email))].sort();
}
/** Thrown for join failures the guest may see. */
export class JoinError extends Error {}
/** The shares database (`shares.sqlite`); listeners hear of every committed change of links, guests and grants. */
export class ShareDb {
readonly #db: DatabaseSync;
readonly #listeners = new Set<() => void>();
constructor(directory: string) {
const path = join(directory, "shares.sqlite");
this.#db = new DatabaseSync(path);
chmodSync(path, 0o600);
this.#db.exec("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;");
const version = Number(this.#db.prepare("PRAGMA user_version").get()?.user_version ?? 0);
if (version !== 0 && version !== 1 && version !== SCHEMA_VERSION) {
this.#db.close();
throw new Error(`Unsupported shares schema: ${version}`);
}
this.#transaction(() => {
this.#db.exec(`
CREATE TABLE IF NOT EXISTS links (
id TEXT PRIMARY KEY, session TEXT NOT NULL, mode TEXT NOT NULL, token TEXT NOT NULL UNIQUE,
UNIQUE(session, mode)
) STRICT;
${GRANTS};
`);
// Version 1: each guest becomes a guest with one grant, the session of its link.
if (version === 1)
this.#db.exec(`
ALTER TABLE guests RENAME TO guests_v1;
${GUESTS};
INSERT INTO guests(id,name,secretHash,revision,lastSeen)
SELECT id,name,secretHash,revision,lastSeen FROM guests_v1;
INSERT INTO grants(guest,session,mode,linkId,removed,grantedAt)
SELECT guests_v1.id,links.session,links.mode,links.id,guests_v1.removed,guests_v1.lastSeen
FROM guests_v1 JOIN links ON links.id=guests_v1.linkId;
DROP TABLE guests_v1;
`);
else this.#db.exec(GUESTS);
this.#db.exec(`PRAGMA user_version=${SCHEMA_VERSION}`);
});
}
/** `listener` hears of every committed change from now on; the result stops it. */
subscribe(listener: () => void): () => void {
this.#listeners.add(listener);
return () => this.#listeners.delete(listener);
}
#transaction<T>(fn: () => T): T {
this.#db.exec("BEGIN");
let result: T;
try {
result = fn();
this.#db.exec("COMMIT");
} catch (error) {
this.#db.exec("ROLLBACK");
throw error;
}
for (const listener of this.#listeners) listener();
return result;
}
links(): ShareLink[] {
return this.#db
.prepare("SELECT id,session,mode,token FROM links")
.all()
.map((row) => ({
id: String(row.id),
session: String(row.session),
mode: row.mode as ShareMode,
token: String(row.token),
}))
.sort((a, b) => a.session.localeCompare(b.session) || MODES.indexOf(a.mode) - MODES.indexOf(b.mode));
}
linkByToken(token: string): ShareLink | undefined {
if (!/^[\w-]{43}$/.test(token)) return undefined;
return this.links().find((link) => link.token === token);
}
/**
* Creates the session's link of `mode`, or replaces it. Replacing ends the grants made through the old link; returns
* the guests whose grants ended.
*/
link(session: string, mode: ShareMode, reset: boolean): string[] {
const existing = this.links().find((link) => link.session === session && link.mode === mode);
if (existing && !reset) return [];
return this.#transaction(() => {
let ended: string[] = [];
if (existing) {
ended = this.#db
.prepare("SELECT guest FROM grants WHERE linkId=?")
.all(existing.id)
.map((row) => String(row.guest));
this.#db.prepare("DELETE FROM grants WHERE linkId=?").run(existing.id);
this.#db.prepare("DELETE FROM links WHERE id=?").run(existing.id);
for (const id of ended) this.#bump(id);
}
this.#db
.prepare("INSERT INTO links(id,session,mode,token) VALUES(?,?,?,?)")
.run(randomUUID(), session, mode, randomBytes(32).toString("base64url"));
return ended;
});
}
guest(id: string): GuestRow | undefined {
const row = this.#db.prepare("SELECT id,name,email,revision FROM guests WHERE id=?").get(id);
if (!row) return undefined;
return {
id: String(row.id),
name: String(row.name),
email: row.email === null ? null : String(row.email),
revision: Number(row.revision),
};
}
/** The guest of a cookie value `<id>.<secret>` whose secret matches. */
guestOfCookie(cookie: string): GuestRow | undefined {
const [id, secret] = cookie.split(".");
if (!id || !secret || secret.length !== 64) return undefined;
const row = this.#db.prepare("SELECT secretHash FROM guests WHERE id=?").get(id);
return row?.secretHash === hash(secret) ? this.guest(id) : undefined;
}
/** The guest of a normalized email. */
guestOfEmail(email: string): GuestRow | undefined {
const row = this.#db.prepare("SELECT id FROM guests WHERE email=?").get(email);
return row ? this.guest(String(row.id)) : undefined;
}
/** A guest's grants, removed ones included. */
grants(id: string): GrantRow[] {
return this.#db
.prepare("SELECT session,mode,linkId,removed,grantedAt FROM grants WHERE guest=?")
.all(id)
.map((row) => ({
session: String(row.session),
mode: row.mode as ShareMode,
linkId: row.linkId === null ? null : String(row.linkId),
removed: row.removed === 1,
grantedAt: Number(row.grantedAt),
}));
}
/** Blocks a guest's grant of `session`; true if it had one. A removed browser cannot join that session again. */
remove(session: string, guestId: string): boolean {
return this.#transaction(() => {
const result = this.#db
.prepare("UPDATE grants SET removed=1 WHERE guest=? AND session=? AND removed=0")
.run(guestId, session);
if (Number(result.changes) === 0) return false;
this.#bump(guestId);
return true;
});
}
/** Bumps the revision of every guest of `session` (archive, restore); returns their IDs. */
bump(session: string): string[] {
return this.#transaction(() => {
const ids = this.#db
.prepare("SELECT guest FROM grants WHERE session=?")
.all(session)
.map((row) => String(row.guest));
for (const id of ids) this.#bump(id);
return ids;
});
}
/**
* Joins `link`: `existing` (the browser's guest, by its cookie) gains the link's session, else a new guest is created
* with the cleaned name, made unique ("Alice (2)"), and a cookie. Joining a session again takes the link's mode.
* `changed` says whether an existing guest's grants changed (its revision was bumped).
*/
join(
link: ShareLink,
requestedName: string,
existing?: GuestRow,
): { guest: string; cookie?: string; changed: boolean } {
return this.#transaction(() => {
const grant = existing && this.grants(existing.id).find((entry) => entry.session === link.session);
if (grant?.removed) throw new JoinError("You were removed from this session");
if (existing && grant && grant.mode === link.mode && grant.linkId === link.id)
return { guest: existing.id, changed: false };
if (
Number(this.#db.prepare("SELECT count(*) AS n FROM grants WHERE linkId=?").get(link.id)?.n) >=
MAX_GUESTS_PER_LINK
)
throw new JoinError("Guest limit reached");
let id = existing?.id;
let cookie: string | undefined;
if (id === undefined) {
id = randomUUID();
const secret = randomBytes(32).toString("hex");
this.#db
.prepare("INSERT INTO guests(id,name,secretHash,lastSeen) VALUES(?,?,?,?)")
.run(id, this.#uniqueName(requestedName), hash(secret), Date.now());
cookie = `${id}.${secret}`;
}
this.#grant(id, link.session, link.mode, link.id);
if (existing) this.#bump(id);
return { guest: id, ...(cookie ? { cookie } : {}), changed: existing !== undefined };
});
}
/**
* Shares `session` with the person of `email` (normalized; `name` is how the owner wrote it), creating their guest
* first, and gives a removed person access again. Returns the guest and whether an existing guest changed.
*/
share(session: string, email: string, name: string, mode: ShareMode): { guest: string; changed: boolean } {
return this.#transaction(() => {
const existing = this.guestOfEmail(email);
const id = existing?.id ?? randomUUID();
if (!existing)
this.#db.prepare("INSERT INTO guests(id,name,email,lastSeen) VALUES(?,?,?,?)").run(id, name, email, 0);
this.#grant(id, session, mode, null);
if (existing) this.#bump(id);
return { guest: id, changed: existing !== undefined };
});
}
/** Every grant to a person by email that is not removed, by name. */
people(): PersonRow[] {
return this.#db
.prepare(
"SELECT guests.id,guests.name,grants.session,grants.mode FROM grants JOIN guests ON guests.id=grants.guest WHERE grants.removed=0 AND guests.email IS NOT NULL ORDER BY guests.name",
)
.all()
.map((row) => ({
id: String(row.id),
name: String(row.name),
session: String(row.session),
mode: row.mode as ShareMode,
}));
}
/** Every grant to a person by email that is not removed. */
emailGrants(): EmailGrant[] {
return this.#db
.prepare(
"SELECT guests.email,grants.session FROM grants JOIN guests ON guests.id=grants.guest WHERE grants.removed=0 AND guests.email IS NOT NULL",
)
.all()
.map((row) => ({ email: String(row.email), session: String(row.session) }));
}
/** Every email with a grant that is not removed, on a session `open` accepts; sorted. */
emails(open: (session: string) => boolean): string[] {
return openEmails(this.emailGrants(), open);
}
seen(id: string): void {
this.#db.prepare("UPDATE guests SET lastSeen=? WHERE id=?").run(Date.now(), id);
}
close(): void {
this.#db.close();
}
#grant(guest: string, session: string, mode: ShareMode, linkId: string | null): void {
this.#db
.prepare(
"INSERT INTO grants(guest,session,mode,linkId,removed,grantedAt) VALUES(?,?,?,?,0,?) ON CONFLICT(guest,session) DO UPDATE SET mode=excluded.mode, linkId=excluded.linkId, removed=0, grantedAt=excluded.grantedAt",
)
.run(guest, session, mode, linkId, Date.now());
}
#bump(guest: string): void {
this.#db.prepare("UPDATE guests SET revision=revision+1 WHERE id=?").run(guest);
}
#uniqueName(requested: string): string {
const name = requested
.normalize("NFKC")
.replace(/[\p{C}<>]/gu, "")
.trim()
.slice(0, 40);
if (!name) throw new JoinError("Choose a guest name");
const count = Number(
this.#db.prepare("SELECT count(*) AS n FROM guests WHERE name=? OR name LIKE ?").get(name, `${name} (%)`)?.n ??
0,
);
return count ? `${name} (${count + 1})` : name;
}
}