Skip to content

Commit

The docs service has tables for artifacts beside Docs' pages, and one tested rule for who can read and change an artifact: its owner, shares on it or above it, its space, and everyone in the workspace or with the link.

syntaqxcommitted Parent92005ccBrowse files
3 files+722−00/3 viewed
+269−0
1+-- Folios: what people call artifacts. Artifacts mode is one mode for docs,
2+-- slides, designs and dashboards (docs/ARTIFACTS_MODE.md, section 2.2).
3+-- A folio's live content is a Yjs document in its room (FolioRoom,
4+-- src/folios/room.ts); these tables keep everything around it and the
5+-- text rendition the room saves after each burst of edits.
6+--
7+-- This migration only adds tables. Docs' pages keep working on theirs
8+-- until Phase 7 drops them. Keys are as in Docs: `user:<id>`,
9+-- `agent:<id>`, `team:<slug>`.
10+
11+CREATE TABLE folios (
12+ id TEXT PRIMARY KEY,
13+ workspace_id TEXT NOT NULL,
14+ kind TEXT NOT NULL CHECK (kind IN ('doc', 'slides', 'design', 'dashboard')),
15+ title TEXT NOT NULL DEFAULT '',
16+ icon TEXT,
17+ cover TEXT,
18+ -- Always a person: `user:<id>`. An agent's folio is owned by whoever it acted for.
19+ owner TEXT NOT NULL,
20+ -- NULL: the owner's Private section.
21+ space_id TEXT REFERENCES spaces (id),
22+ -- Only a doc is ever a parent. Children share their parent's space.
23+ parent_id TEXT REFERENCES folios (id),
24+ position REAL NOT NULL,
25+ -- 1: follows its parent (or, at the top, its space). 0: "Only people invited".
26+ inherit INTEGER NOT NULL DEFAULT 1,
27+ -- The nearest of itself and its ancestors with inherit = 0 or no parent.
28+ acl_root TEXT NOT NULL,
29+ -- '/<top id>/…/<id>/', for subtree updates.
30+ path TEXT NOT NULL,
31+ general_access TEXT NOT NULL DEFAULT 'none' CHECK (general_access IN ('none', 'workspace', 'link')),
32+ general_role TEXT CHECK (general_role IN ('view', 'comment', 'edit')),
33+ -- NULL: the space's, or 'suggest' in Private.
34+ agent_mode TEXT CHECK (agent_mode IN ('suggest', 'edit')),
35+ -- The kind's text rendition: search, recall, the read view, export.
36+ text TEXT NOT NULL DEFAULT '',
37+ excerpt TEXT NOT NULL DEFAULT '',
38+ -- Small JSON a card draws; never data values.
39+ preview TEXT,
40+ -- JSON { title, href }: where it was written up from.
41+ source TEXT,
42+ -- People already told they were mentioned in it.
43+ mentioned TEXT NOT NULL DEFAULT '[]',
44+ created_by TEXT NOT NULL,
45+ created_at TEXT NOT NULL,
46+ -- Any change: rename, move, share.
47+ updated_by TEXT,
48+ updated_at TEXT NOT NULL,
49+ -- Content changes: "Edited 45m ago".
50+ edited_by TEXT,
51+ edited_at TEXT NOT NULL,
52+ trashed_at TEXT,
53+ trashed_by TEXT
54+);
55+CREATE INDEX folios_tree ON folios (workspace_id, space_id, parent_id, position);
56+CREATE INDEX folios_owner ON folios (owner, edited_at) WHERE trashed_at IS NULL;
57+CREATE INDEX folios_recent ON folios (workspace_id, edited_at) WHERE trashed_at IS NULL;
58+CREATE INDEX folios_trashed ON folios (workspace_id, trashed_at) WHERE trashed_at IS NOT NULL;
59+CREATE INDEX folios_root ON folios (acl_root);
60+CREATE INDEX folios_path ON folios (path);
61+CREATE INDEX folios_parent ON folios (parent_id);
62+
63+-- Explicit shares, as they were set.
64+CREATE TABLE folio_grants (
65+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
66+ principal TEXT NOT NULL,
67+ role TEXT NOT NULL CHECK (role IN ('view', 'comment', 'edit', 'manage')),
68+ granted_by TEXT NOT NULL,
69+ granted_at TEXT NOT NULL,
70+ PRIMARY KEY (folio_id, principal)
71+);
72+
73+-- Effective explicit access, per folio, for list and search SQL: the
74+-- owner, the owners of ancestors it inherits from (as `manage`), and every
75+-- grant from the folio up to its acl_root, highest role each. Rebuilt for
76+-- a subtree (by `path`) on a grant, move, restriction or ownership change
77+-- (src/folios/access-store.ts).
78+CREATE TABLE folio_access (
79+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
80+ principal TEXT NOT NULL,
81+ role TEXT NOT NULL,
82+ -- The folio whose grant or owner this is (itself or an ancestor).
83+ via TEXT NOT NULL,
84+ -- For "Shared with you" ordering.
85+ since TEXT NOT NULL,
86+ PRIMARY KEY (folio_id, principal)
87+);
88+CREATE INDEX folio_access_principal ON folio_access (principal, since);
89+
90+-- Recent, and link access once opened.
91+CREATE TABLE folio_visits (
92+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
93+ user_id TEXT NOT NULL,
94+ first_at TEXT NOT NULL,
95+ last_at TEXT NOT NULL,
96+ PRIMARY KEY (folio_id, user_id)
97+);
98+CREATE INDEX folio_visits_user ON folio_visits (user_id, last_at);
99+
100+CREATE TABLE folio_favorites (
101+ user_id TEXT NOT NULL,
102+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
103+ position REAL NOT NULL,
104+ created_at TEXT NOT NULL,
105+ PRIMARY KEY (user_id, folio_id)
106+);
107+
108+-- Open spaces a member shows in their sidebar.
109+CREATE TABLE space_joins (
110+ space_id TEXT NOT NULL REFERENCES spaces (id) ON DELETE CASCADE,
111+ user_id TEXT NOT NULL,
112+ position REAL NOT NULL,
113+ joined_at TEXT NOT NULL,
114+ PRIMARY KEY (space_id, user_id)
115+);
116+CREATE INDEX space_joins_user ON space_joins (user_id);
117+
118+-- History: the Yjs state (an exact restore) and the text (reading, diffs).
119+CREATE TABLE folio_versions (
120+ id TEXT PRIMARY KEY,
121+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
122+ created_at TEXT NOT NULL,
123+ kind TEXT NOT NULL CHECK (kind IN ('created', 'edit', 'agent', 'suggestion', 'proposal', 'restore')),
124+ authors TEXT NOT NULL DEFAULT '[]',
125+ note TEXT,
126+ text TEXT NOT NULL,
127+ -- The Yjs state when it is at most 1.5 MB.
128+ state BLOB,
129+ -- Else its key in the file store.
130+ state_key TEXT
131+);
132+CREATE INDEX folio_versions_folio ON folio_versions (folio_id, created_at);
133+
134+-- A doc's agents' tracked changes, as `suggestions` for pages.
135+CREATE TABLE folio_suggestions (
136+ id TEXT PRIMARY KEY,
137+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
138+ author TEXT NOT NULL,
139+ asked_by TEXT,
140+ target TEXT NOT NULL,
141+ before_markdown TEXT NOT NULL,
142+ after_markdown TEXT NOT NULL,
143+ note TEXT,
144+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'accepted', 'rejected', 'stale')),
145+ created_at TEXT NOT NULL,
146+ decided_by TEXT,
147+ decided_at TEXT,
148+ marks_current INTEGER NOT NULL DEFAULT 0
149+);
150+CREATE INDEX folio_suggestions_folio ON folio_suggestions (folio_id, status);
151+
152+-- Other kinds: an agent's whole change as a Yjs update, previewed and applied or rejected.
153+CREATE TABLE folio_proposals (
154+ id TEXT PRIMARY KEY,
155+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
156+ author TEXT NOT NULL,
157+ asked_by TEXT,
158+ note TEXT,
159+ base_vector BLOB NOT NULL,
160+ update_blob BLOB,
161+ update_key TEXT,
162+ summary TEXT NOT NULL,
163+ status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'accepted', 'rejected', 'stale')),
164+ created_at TEXT NOT NULL,
165+ decided_by TEXT,
166+ decided_at TEXT
167+);
168+CREATE INDEX folio_proposals_folio ON folio_proposals (folio_id, status);
169+
170+-- The workspace's own templates; the built-in ones are in code.
171+CREATE TABLE folio_templates (
172+ id TEXT PRIMARY KEY,
173+ workspace_id TEXT NOT NULL,
174+ kind TEXT NOT NULL CHECK (kind IN ('doc', 'slides', 'design', 'dashboard')),
175+ name TEXT NOT NULL,
176+ description TEXT NOT NULL DEFAULT '',
177+ icon TEXT,
178+ -- Markdown (doc, slides) or a JSON spec (design, dashboard).
179+ body TEXT NOT NULL,
180+ created_by TEXT NOT NULL,
181+ created_at TEXT NOT NULL
182+);
183+CREATE INDEX folio_templates_ws ON folio_templates (workspace_id, kind);
184+
185+-- Files put in folios, kept in the file store under `docs/<key>` like pages' files.
186+CREATE TABLE folio_files (
187+ id TEXT PRIMARY KEY,
188+ workspace_id TEXT NOT NULL,
189+ folio_id TEXT REFERENCES folios (id) ON DELETE SET NULL,
190+ key TEXT NOT NULL UNIQUE,
191+ name TEXT NOT NULL,
192+ content_type TEXT NOT NULL,
193+ bytes INTEGER NOT NULL,
194+ created_by TEXT NOT NULL,
195+ created_at TEXT NOT NULL
196+);
197+CREATE INDEX folio_files_folio ON folio_files (folio_id);
198+
199+-- Links from one folio to another, rebuilt on each save: backlinks.
200+CREATE TABLE folio_links (
201+ from_folio TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
202+ to_folio TEXT NOT NULL,
203+ PRIMARY KEY (from_folio, to_folio)
204+);
205+CREATE INDEX folio_links_to ON folio_links (to_folio);
206+
207+-- Projects (repositories, `owner/name` lowercased) a folio is about.
208+CREATE TABLE folio_projects (
209+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
210+ repo TEXT NOT NULL,
211+ PRIMARY KEY (folio_id, repo)
212+);
213+CREATE INDEX folio_projects_repo ON folio_projects (repo);
214+
215+-- Code a folio cites, as `citations` for pages.
216+CREATE TABLE folio_citations (
217+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
218+ repo TEXT NOT NULL,
219+ path TEXT NOT NULL,
220+ kind TEXT NOT NULL CHECK (kind IN ('path', 'symbol', 'endpoint', 'env')),
221+ label TEXT NOT NULL DEFAULT '',
222+ ref TEXT,
223+ source TEXT NOT NULL CHECK (source IN ('body', 'header')),
224+ PRIMARY KEY (folio_id, source, repo, path, kind, label)
225+);
226+CREATE INDEX folio_citations_repo ON folio_citations (repo);
227+
228+-- Changes that touched code a folio cites, as `page_changes`.
229+CREATE TABLE folio_changes (
230+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
231+ repo TEXT NOT NULL,
232+ repo_id TEXT NOT NULL,
233+ commit_sha TEXT NOT NULL,
234+ pull_number INTEGER,
235+ pull_title TEXT,
236+ paths TEXT NOT NULL DEFAULT '[]',
237+ detected_at TEXT NOT NULL,
238+ cleared_at TEXT,
239+ cleared_by TEXT,
240+ PRIMARY KEY (folio_id, repo, commit_sha)
241+);
242+CREATE INDEX folio_changes_open ON folio_changes (folio_id) WHERE cleared_at IS NULL;
243+CREATE INDEX folio_changes_recent ON folio_changes (detected_at) WHERE cleared_at IS NULL;
244+
245+-- Full text over titles and text renditions; rebuilt for a folio on each save.
246+CREATE VIRTUAL TABLE folios_fts USING fts5 (folio_id UNINDEXED, kind UNINDEXED, title, body, tokenize = 'unicode61 remove_diacritics 2');
247+
248+-- Folios' passages for the semantic index (Vectorize `g1t-folios`) and
249+-- recall. `scope` is 'space:<id>' when the folio's access is exactly its
250+-- space's, else 'folio:<acl_root>'; every hit is checked again against
251+-- these tables before anyone sees it. Projects' docs keep `doc_chunks`.
252+CREATE TABLE folio_chunks (
253+ id TEXT PRIMARY KEY,
254+ workspace_id TEXT NOT NULL,
255+ folio_id TEXT NOT NULL,
256+ kind TEXT NOT NULL,
257+ scope TEXT NOT NULL,
258+ seq INTEGER NOT NULL,
259+ heading TEXT,
260+ text TEXT NOT NULL,
261+ hash TEXT NOT NULL,
262+ vector_hash TEXT,
263+ updated_at TEXT NOT NULL
264+);
265+CREATE INDEX folio_chunks_folio ON folio_chunks (folio_id, seq);
266+CREATE INDEX folio_chunks_workspace ON folio_chunks (workspace_id);
267+CREATE INDEX folio_chunks_unembedded ON folio_chunks (workspace_id) WHERE vector_hash IS NULL OR vector_hash <> hash;
268+
269+CREATE VIRTUAL TABLE folio_chunks_fts USING fts5 (chunk_id UNINDEXED, scope UNINDEXED, folio_id UNINDEXED, heading, text, tokenize = 'unicode61 remove_diacritics 2');
+201−0
113113 if (!id || (kind !== "user" && kind !== "agent" && kind !== "team")) return null;
114114 return { kind, id };
115115 }
116+
117+// ── Folios (Artifacts mode, docs/ARTIFACTS_MODE.md section 2.1) ─────────
118+//
119+// A person's role on a folio is the highest of:
120+// 1. its owner → `manage` (and the owner of every ancestor it inherits
121+// from, as if that owner held a `manage` grant there);
122+// 2. grants on it or on an ancestor it inherits from, to their `user:` key
123+// or one of their `team:` keys;
124+// 3. their space role, when the chain reaches the top of a space without a
125+// restriction (`inheritsSpace`): workspace owners manage those, as for
126+// pages, through `roleOf`;
127+// 4. the access root's general access: `workspace` gives every member its
128+// role, `link` gives it to members who opened the link (a visit).
129+//
130+// A restricted folio (`inherit` false) is its own access root: grants
131+// above it, its space and its parent's general access stop there. An
132+// agent never has more than the person it acts for, narrowed to what
133+// everyone it is talking to can read.
134+
135+/** One folio as access needs it. */
136+export type FolioAclNode = {
137+ id: string;
138+ /** `user:<id>`. */
139+ owner: string;
140+ parent_id: string | null;
141+ space_id: string | null;
142+ /** False: "Only people invited", its own access root. */
143+ inherit: boolean;
144+ general_access: "none" | "workspace" | "link";
145+ general_role: DocRole | null;
146+ created_at?: string;
147+};
148+
149+/** An explicit share, as set. */
150+export type FolioGrant = { principal: string; role: DocRole; granted_at?: string };
151+
152+/** Grants by folio id. */
153+export type FolioGrants = ReadonlyMap<string, readonly FolioGrant[]>;
154+
155+/** The deepest a chain is followed: the tree's depth cap, with room. */
156+const MAX_CHAIN = 32;
157+
158+/** The keys a person's grants can name: `user:<id>` and each `team:<slug>`. */
159+export function personKeys(person: Person): string[] {
160+ return [`user:${person.user_id}`, ...[...person.teams].map((t) => `team:${t.toLowerCase()}`)];
161+}
162+
163+/** Where a folio's access comes from: itself when restricted or at the top, else its parent's access root. */
164+export function aclRootOf(node: { id: string; inherit: boolean; parent_id: string | null }, parentAclRoot: string | null): string {
165+ return !node.inherit || !node.parent_id || !parentAclRoot ? node.id : parentAclRoot;
166+}
167+
168+/** A folio's path: `/<top id>/…/<id>/`. */
169+export function folioPathOf(id: string, parentPath: string | null): string {
170+ return parentPath ? `${parentPath}${id}/` : `/${id}/`;
171+}
172+
173+/**
174+ * The chain access is read along: the folio, then each ancestor it
175+ * inherits from, up to and including its access root. A missing parent
176+ * ends the chain there.
177+ */
178+export function aclChain(id: string, byId: ReadonlyMap<string, FolioAclNode>): FolioAclNode[] {
179+ const out: FolioAclNode[] = [];
180+ let at = byId.get(id);
181+ const seen = new Set<string>();
182+ while (at && !seen.has(at.id) && out.length < MAX_CHAIN) {
183+ out.push(at);
184+ seen.add(at.id);
185+ if (!at.inherit || !at.parent_id) break;
186+ at = byId.get(at.parent_id);
187+ }
188+ return out;
189+}
190+
191+/** Whether a chain's access root takes its space's access: at the top of a space, not restricted. */
192+export function inheritsSpace(chain: readonly FolioAclNode[]): boolean {
193+ const root = chain[chain.length - 1];
194+ return !!root && !!root.space_id && root.inherit && !root.parent_id;
195+}
196+
197+/** General access never gives `manage`. */
198+export function generalRoleCap(role: DocRole | null | undefined): DocRole {
199+ if (!role) return "view";
200+ return role === "manage" ? "edit" : role;
201+}
202+
203+/** One principal's explicit access to a folio: its role, whose grant or ownership it is, and since when. */
204+export type FolioAccessEntry = { role: DocRole; via: string; since: string };
205+
206+/**
207+ * Explicit access along a chain: the folio's owner (`via` "owner"), the
208+ * owners of the ancestors it inherits from, and every grant up to the
209+ * access root; the highest role per principal, the nearest on a tie.
210+ */
211+export function explicitAccess(chain: readonly FolioAclNode[], grants: FolioGrants): Map<string, FolioAccessEntry> {
212+ const out = new Map<string, FolioAccessEntry>();
213+ const put = (principal: string, role: DocRole, via: string, since: string) => {
214+ const was = out.get(principal);
215+ if (!was || RANK[role] > RANK[was.role]) out.set(principal, { role, via, since });
216+ };
217+ chain.forEach((node, i) => {
218+ put(node.owner, "manage", i === 0 ? "owner" : node.id, node.created_at ?? "");
219+ for (const g of grants.get(node.id) ?? []) put(g.principal, g.role, node.id, g.granted_at ?? "");
220+ });
221+ return out;
222+}
223+
224+/**
225+ * A person's role on a folio, or null when they can't read it. `chain` is
226+ * `aclChain`'s; `spaceRole` is their role in the folio's space (`roleOf`),
227+ * used only when the chain inherits it; `visited` says they opened the
228+ * folio's link (or its access root's).
229+ */
230+export function effectiveRole(chain: readonly FolioAclNode[], grants: FolioGrants, spaceRole: DocRole | null, person: Person, options: { visited?: boolean } = {}): DocRole | null {
231+ const root = chain[chain.length - 1];
232+ if (!root) return null;
233+ const keys = new Set(personKeys(person));
234+ let role: DocRole | null = null;
235+ for (const [principal, entry] of explicitAccess(chain, grants)) if (keys.has(principal)) role = higher(role, entry.role);
236+ if (inheritsSpace(chain)) role = higher(role, spaceRole);
237+ if (root.general_access === "workspace") role = higher(role, generalRoleCap(root.general_role));
238+ if (root.general_access === "link" && options.visited) role = higher(role, generalRoleCap(root.general_role));
239+ return role;
240+}
241+
242+/** The lock: only the folio's owner can read it (no other owners or grants, no general access, no space it inherits). */
243+export function isPrivateFolio(chain: readonly FolioAclNode[], grants: FolioGrants): boolean {
244+ const root = chain[chain.length - 1];
245+ const self = chain[0];
246+ if (!root || !self) return true;
247+ if (root.general_access !== "none" || inheritsSpace(chain)) return false;
248+ for (const principal of explicitAccess(chain, grants).keys()) if (principal !== self.owner) return false;
249+ return true;
250+}
251+
252+/**
253+ * Whether "everyone in the workspace" can read a folio: a public
254+ * channel's audience. Only through an open space it inherits (with a base
255+ * role) or general access `workspace`; never a link, grants or Private.
256+ */
257+export function folioReadableByWorkspace(chain: readonly FolioAclNode[], space: SpaceRules | null): boolean {
258+ const root = chain[chain.length - 1];
259+ if (!root) return false;
260+ if (root.general_access === "workspace") return true;
261+ return inheritsSpace(chain) && !!space && readableByWorkspace(space);
262+}
263+
264+/** Whether every one of `people` can read a folio. `visited` says whether a person opened its link. */
265+export function folioReadableByAll(
266+ chain: readonly FolioAclNode[],
267+ grants: FolioGrants,
268+ space: SpaceRules | null,
269+ people: readonly Person[],
270+ visited: (person: Person) => boolean = () => false,
271+): boolean {
272+ return people.every((person) => atLeast(effectiveRole(chain, grants, space ? roleOf(space, person) : null, person, { visited: visited(person) }), "view"));
273+}
274+
275+/**
276+ * The index scope a folio's passages are filed under: its space's when
277+ * its access is exactly the space's (inherits to the top, nothing shared
278+ * beyond its owners, no general access); otherwise its access root's.
279+ */
280+export function folioScope(chain: readonly FolioAclNode[], grants: FolioGrants): string {
281+ const root = chain[chain.length - 1];
282+ if (!root) return "folio:unknown";
283+ if (inheritsSpace(chain) && root.general_access === "none") {
284+ const owners = new Set(chain.map((n) => n.owner));
285+ const shared = [...explicitAccess(chain, grants).keys()].some((p) => !owners.has(p));
286+ if (!shared) return `space:${root.space_id}`;
287+ }
288+ return `folio:${root.id}`;
289+}
290+
291+export type FolioAccessRow = { folio_id: string; principal: string; role: DocRole; via: string; since: string };
292+
293+/** The rows of `folio_access` for these folios: everything `explicitAccess` finds along each one's chain. */
294+export function materialize(ids: readonly string[], byId: ReadonlyMap<string, FolioAclNode>, grants: FolioGrants): FolioAccessRow[] {
295+ const out: FolioAccessRow[] = [];
296+ for (const id of ids) {
297+ const chain = aclChain(id, byId);
298+ if (!chain.length) continue;
299+ for (const [principal, entry] of explicitAccess(chain, grants)) out.push({ folio_id: id, principal, role: entry.role, via: entry.via, since: entry.since });
300+ }
301+ return out;
302+}
303+
304+/** Whether `role` may change who a folio is shared with: `manage`, or `edit` where editors may share. */
305+export function canShare(role: DocRole | null, editorsCanShare = false): boolean {
306+ return atLeast(role, "manage") || (editorsCanShare && atLeast(role, "edit"));
307+}
308+
309+/**
310+ * An agent's role on a folio: never more than its asker's, and nothing
311+ * when someone it is talking to can't read the folio. The agent's own
312+ * grants never widen this; they make it a participant, nothing more.
313+ */
314+export function agentFolioRole(askerRole: DocRole | null, audienceCanRead: boolean): DocRole | null {
315+ return audienceCanRead ? askerRole : null;
316+}
+252−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ aclChain,
6+ aclRootOf,
7+ agentAbilities,
8+ agentFolioRole,
9+ canShare,
10+ effectiveRole,
11+ explicitAccess,
12+ folioPathOf,
13+ folioReadableByAll,
14+ folioReadableByWorkspace,
15+ folioScope,
16+ generalRoleCap,
17+ inheritsSpace,
18+ isPrivateFolio,
19+ materialize,
20+ roleOf,
21+ type FolioAclNode,
22+ type FolioGrant,
23+ type Person,
24+ type SpaceRules,
25+} from "./access.ts";
26+
27+// People: Ana (team web), Bo (no team), Cy (team design), Wes (workspace owner).
28+const ana: Person = { user_id: "ana", owner: false, teams: new Set(["web"]) };
29+const bo: Person = { user_id: "bo", owner: false, teams: new Set() };
30+const cy: Person = { user_id: "cy", owner: false, teams: new Set(["design"]) };
31+const wes: Person = { user_id: "wes", owner: true, teams: new Set() };
32+
33+// Spaces.
34+const open: SpaceRules = { kind: "workspace", team: null, default_role: "edit", members: [] };
35+const openView: SpaceRules = { kind: "workspace", team: null, default_role: "view", members: [] };
36+const team: SpaceRules = { kind: "team", team: "web", default_role: "comment", members: [] };
37+const membersOnly: SpaceRules = { kind: "private", team: null, default_role: null, members: [{ principal: "user:cy", role: "manage" }] };
38+const SPACES: Record<string, SpaceRules> = { open, openView, team, membersOnly };
39+
40+function node(id: string, over: Partial<FolioAclNode> = {}): FolioAclNode {
41+ return { id, owner: "user:ana", parent_id: null, space_id: null, inherit: true, general_access: "none", general_role: null, created_at: "2026-10-01T00:00:00Z", ...over };
42+}
43+
44+/** The role a person has on `id`, given every node and grant. */
45+function roleIn(nodes: FolioAclNode[], grants: Record<string, FolioGrant[]>, id: string, person: Person, visited = false) {
46+ const byId = new Map(nodes.map((n) => [n.id, n]));
47+ const chain = aclChain(id, byId);
48+ const root = chain[chain.length - 1]!;
49+ const space = root.space_id ? SPACES[root.space_id]! : null;
50+ return effectiveRole(chain, new Map(Object.entries(grants)), space ? roleOf(space, person) : null, person, { visited });
51+}
52+
53+test("private: only the owner, and not the workspace owner", () => {
54+ const nodes = [node("f")];
55+ assert.equal(roleIn(nodes, {}, "f", ana), "manage");
56+ assert.equal(roleIn(nodes, {}, "f", bo), null);
57+ assert.equal(roleIn(nodes, {}, "f", wes), null);
58+ const byId = new Map(nodes.map((n) => [n.id, n]));
59+ assert.equal(isPrivateFolio(aclChain("f", byId), new Map()), true);
60+});
61+
62+test("an open space gives every member its base role; owners manage", () => {
63+ const nodes = [node("f", { space_id: "open", owner: "user:cy" })];
64+ assert.equal(roleIn(nodes, {}, "f", ana), "edit");
65+ assert.equal(roleIn(nodes, {}, "f", bo), "edit");
66+ assert.equal(roleIn(nodes, {}, "f", cy), "manage");
67+ assert.equal(roleIn(nodes, {}, "f", wes), "manage");
68+ const byId = new Map(nodes.map((n) => [n.id, n]));
69+ assert.equal(isPrivateFolio(aclChain("f", byId), new Map()), false);
70+});
71+
72+test("a team space: its team gets the base role; others nothing; workspace owners manage", () => {
73+ const nodes = [node("f", { space_id: "team", owner: "user:cy" })];
74+ assert.equal(roleIn(nodes, {}, "f", ana), "comment");
75+ assert.equal(roleIn(nodes, {}, "f", bo), null);
76+ assert.equal(roleIn(nodes, {}, "f", wes), "manage");
77+});
78+
79+test("a members-only space: only its members, never the workspace owner", () => {
80+ const nodes = [node("f", { space_id: "membersOnly", owner: "user:cy" })];
81+ assert.equal(roleIn(nodes, {}, "f", cy), "manage");
82+ assert.equal(roleIn(nodes, {}, "f", ana), null);
83+ assert.equal(roleIn(nodes, {}, "f", wes), null);
84+});
85+
86+test("grants to a person, an agent and a team", () => {
87+ const nodes = [node("f")];
88+ const grants = { f: [{ principal: "user:bo", role: "comment" as const }, { principal: "team:design", role: "edit" as const }, { principal: "agent:ag1", role: "edit" as const }] };
89+ assert.equal(roleIn(nodes, grants, "f", bo), "comment");
90+ assert.equal(roleIn(nodes, grants, "f", cy), "edit");
91+ // An agent grant gives no person anything.
92+ assert.equal(roleIn(nodes, grants, "f", wes), null);
93+ const byId = new Map(nodes.map((n) => [n.id, n]));
94+ assert.equal(isPrivateFolio(aclChain("f", byId), new Map(Object.entries(grants))), false);
95+});
96+
97+test("a grant raises a space role but never lowers it", () => {
98+ const nodes = [node("f", { space_id: "openView", owner: "user:cy" })];
99+ assert.equal(roleIn(nodes, { f: [{ principal: "user:bo", role: "edit" }] }, "f", bo), "edit");
100+ assert.equal(roleIn(nodes, { f: [{ principal: "user:ana", role: "view" }] }, "f", ana), "view");
101+ const open2 = [node("f", { space_id: "open", owner: "user:cy" })];
102+ assert.equal(roleIn(open2, { f: [{ principal: "user:ana", role: "view" }] }, "f", ana), "edit");
103+});
104+
105+test("general access: workspace gives every member its role, never manage", () => {
106+ const nodes = [node("f", { general_access: "workspace", general_role: "comment" })];
107+ assert.equal(roleIn(nodes, {}, "f", bo), "comment");
108+ assert.equal(roleIn(nodes, {}, "f", wes), "comment");
109+ const capped = [node("f", { general_access: "workspace", general_role: "manage" })];
110+ assert.equal(roleIn(capped, {}, "f", bo), "edit");
111+ assert.equal(generalRoleCap(null), "view");
112+});
113+
114+test("general access: link gives its role only to people who opened it", () => {
115+ const nodes = [node("f", { general_access: "link", general_role: "view" })];
116+ assert.equal(roleIn(nodes, {}, "f", bo), null);
117+ assert.equal(roleIn(nodes, {}, "f", bo, true), "view");
118+});
119+
120+test("nested docs inherit their parent's grants, space and general access", () => {
121+ const nodes = [
122+ node("top", { space_id: "team", owner: "user:cy" }),
123+ node("mid", { space_id: "team", owner: "user:cy", parent_id: "top" }),
124+ node("leaf", { space_id: "team", owner: "user:cy", parent_id: "mid" }),
125+ ];
126+ const grants = { top: [{ principal: "user:bo", role: "view" as const }] };
127+ assert.equal(roleIn(nodes, grants, "leaf", ana), "comment");
128+ assert.equal(roleIn(nodes, grants, "leaf", bo), "view");
129+ assert.equal(roleIn(nodes, grants, "leaf", wes), "manage");
130+ const byId = new Map(nodes.map((n) => [n.id, n]));
131+ assert.deepEqual(
132+ aclChain("leaf", byId).map((n) => n.id),
133+ ["leaf", "mid", "top"],
134+ );
135+ assert.equal(inheritsSpace(aclChain("leaf", byId)), true);
136+});
137+
138+test("a parent's owner keeps full access to what others add under it", () => {
139+ const nodes = [node("top"), node("child", { parent_id: "top", owner: "user:bo" })];
140+ assert.equal(roleIn(nodes, {}, "child", ana), "manage");
141+ assert.equal(roleIn(nodes, {}, "child", bo), "manage");
142+ assert.equal(roleIn(nodes, {}, "child", cy), null);
143+});
144+
145+test("restricting stops the space, the parent's grants and general access", () => {
146+ const nodes = [
147+ node("top", { space_id: "open", owner: "user:cy", general_access: "workspace", general_role: "view" }),
148+ node("secret", { space_id: "open", owner: "user:cy", parent_id: "top", inherit: false }),
149+ node("under", { space_id: "open", owner: "user:cy", parent_id: "secret" }),
150+ ];
151+ const grants = { top: [{ principal: "user:bo", role: "edit" as const }], secret: [{ principal: "user:ana", role: "comment" as const }] };
152+ assert.equal(roleIn(nodes, grants, "secret", bo), null);
153+ assert.equal(roleIn(nodes, grants, "secret", ana), "comment");
154+ assert.equal(roleIn(nodes, grants, "under", ana), "comment");
155+ assert.equal(roleIn(nodes, grants, "under", wes), null);
156+ assert.equal(roleIn(nodes, grants, "under", cy), "manage");
157+ // A restricted top-level folio in a space leaves the space's people out too.
158+ const top = [node("t", { space_id: "open", owner: "user:cy", inherit: false })];
159+ assert.equal(roleIn(top, {}, "t", ana), null);
160+ assert.equal(roleIn(top, {}, "t", wes), null);
161+});
162+
163+test("the access root and path of a new folio", () => {
164+ assert.equal(aclRootOf({ id: "a", inherit: true, parent_id: null }, null), "a");
165+ assert.equal(aclRootOf({ id: "b", inherit: true, parent_id: "a" }, "a"), "a");
166+ assert.equal(aclRootOf({ id: "c", inherit: false, parent_id: "b" }, "a"), "c");
167+ assert.equal(folioPathOf("a", null), "/a/");
168+ assert.equal(folioPathOf("b", "/a/"), "/a/b/");
169+});
170+
171+test("materialize writes the owner, ancestor owners and grants up to the access root, highest role each", () => {
172+ const nodes = [
173+ node("top", { owner: "user:ana" }),
174+ node("child", { parent_id: "top", owner: "user:bo" }),
175+ node("restricted", { parent_id: "child", owner: "user:bo", inherit: false }),
176+ ];
177+ const byId = new Map(nodes.map((n) => [n.id, n]));
178+ const grants = new Map<string, FolioGrant[]>([
179+ ["top", [{ principal: "user:cy", role: "view", granted_at: "2026-10-02T00:00:00Z" }]],
180+ ["child", [{ principal: "user:cy", role: "edit", granted_at: "2026-10-03T00:00:00Z" }]],
181+ ]);
182+ const rows = materialize(["top", "child", "restricted"], byId, grants);
183+ const of = (id: string) => Object.fromEntries(rows.filter((r) => r.folio_id === id).map((r) => [r.principal, `${r.role}@${r.via}`]));
184+ assert.deepEqual(of("top"), { "user:ana": "manage@owner", "user:cy": "view@top" });
185+ assert.deepEqual(of("child"), { "user:bo": "manage@owner", "user:cy": "edit@child", "user:ana": "manage@top" });
186+ assert.deepEqual(of("restricted"), { "user:bo": "manage@owner" });
187+ assert.equal(explicitAccess(aclChain("child", byId), grants).get("user:cy")?.since, "2026-10-03T00:00:00Z");
188+});
189+
190+test("the index scope is the space's only when access is exactly the space's", () => {
191+ const byId = (nodes: FolioAclNode[]) => new Map(nodes.map((n) => [n.id, n]));
192+ const plain = byId([node("a", { space_id: "open" })]);
193+ assert.equal(folioScope(aclChain("a", plain), new Map()), "space:open");
194+ assert.equal(folioScope(aclChain("a", plain), new Map([["a", [{ principal: "user:bo", role: "view" }]]])), "folio:a");
195+ const general = byId([node("a", { space_id: "open", general_access: "workspace", general_role: "view" })]);
196+ assert.equal(folioScope(aclChain("a", general), new Map()), "folio:a");
197+ const nested = byId([node("a", { space_id: "open" }), node("b", { space_id: "open", parent_id: "a", owner: "user:bo" })]);
198+ assert.equal(folioScope(aclChain("b", nested), new Map()), "space:open");
199+ const restricted = byId([node("a", { space_id: "open" }), node("b", { space_id: "open", parent_id: "a", inherit: false })]);
200+ assert.equal(folioScope(aclChain("b", restricted), new Map()), "folio:b");
201+ const priv = byId([node("p")]);
202+ assert.equal(folioScope(aclChain("p", priv), new Map()), "folio:p");
203+});
204+
205+test("readable by the whole workspace: open spaces and workspace general access only", () => {
206+ const byId = (nodes: FolioAclNode[]) => new Map(nodes.map((n) => [n.id, n]));
207+ assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { space_id: "open" })])), open), true);
208+ assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { space_id: "team" })])), team), false);
209+ assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { space_id: "membersOnly" })])), membersOnly), false);
210+ assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a")])), null), false);
211+ assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { general_access: "workspace", general_role: "view" })])), null), true);
212+ // A link is never the workspace's, even for people who opened it.
213+ assert.equal(folioReadableByWorkspace(aclChain("a", byId([node("a", { general_access: "link", general_role: "view" })])), null), false);
214+ // Restricted inside an open space: not the workspace's.
215+ const restricted = byId([node("a", { space_id: "open" }), node("b", { space_id: "open", parent_id: "a", inherit: false })]);
216+ assert.equal(folioReadableByWorkspace(aclChain("b", restricted), open), false);
217+});
218+
219+test("readable by an audience only when every person in it can read", () => {
220+ const nodes = new Map([["f", node("f", { owner: "user:ana" })]]);
221+ const chain = aclChain("f", nodes);
222+ const grants = new Map([["f", [{ principal: "user:bo", role: "view" as const }]]]);
223+ assert.equal(folioReadableByAll(chain, grants, null, [ana, bo]), true);
224+ assert.equal(folioReadableByAll(chain, grants, null, [ana, bo, cy]), false);
225+ const link = new Map([["l", node("l", { general_access: "link", general_role: "view" })]]);
226+ assert.equal(folioReadableByAll(aclChain("l", link), new Map(), null, [ana, bo]), false);
227+ assert.equal(
228+ folioReadableByAll(aclChain("l", link), new Map(), null, [ana, bo], (p) => p.user_id === "bo"),
229+ true,
230+ );
231+});
232+
233+test("an agent is capped by its asker and narrowed by its audience", () => {
234+ // The agent holds an edit grant, its asker only view: it may only read.
235+ const nodes = [node("f", { owner: "user:cy" })];
236+ const grants = { f: [{ principal: "agent:ag1", role: "edit" as const }, { principal: "user:bo", role: "view" as const }] };
237+ const asker = roleIn(nodes, grants, "f", bo);
238+ assert.equal(asker, "view");
239+ assert.equal(agentFolioRole(asker, true), "view");
240+ assert.deepEqual(agentAbilities(agentFolioRole(asker, true), "edit"), { read: true, suggest: false, edit: false });
241+ // Someone in the conversation can't read it: the agent can't either.
242+ assert.equal(agentFolioRole("manage", false), null);
243+ // Someone who can't read it gets nothing from the agent's grant.
244+ assert.equal(agentFolioRole(roleIn(nodes, grants, "f", ana), true), null);
245+});
246+
247+test("who may share", () => {
248+ assert.equal(canShare("manage"), true);
249+ assert.equal(canShare("edit"), false);
250+ assert.equal(canShare("edit", true), true);
251+ assert.equal(canShare(null, true), false);
252+});