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.
3 files+722−00/3 viewed
| 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'); |
| 113 | 113 | if (!id || (kind !== "user" && kind !== "agent" && kind !== "team")) return null; | |
| 114 | 114 | return { kind, id }; | |
| 115 | 115 | } | |
| 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 | + | } |
| 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 | + | }); |