Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

820 linesCodeBlameRaw
1/**
2 * Folios: what people call artifacts. Artifacts mode is one mode for docs,
3 * slides, designs and dashboards, each private, shared with people and
4 * agents, in a space or open to the workspace, and edited live together.
5 * Kept by the artifacts service (`services/artifacts`).
6 *
7 * Naming: code says "folio", people see "artifact" (UI text, URLs
8 * `/<ws>/-/artifacts/...`, the `artifact` MCP tool, REST paths, the
9 * `artifacts:*` scopes). The Cloudflare Artifacts git store and workflow
10 * run artifacts are something else and keep their names.
11 *
12 * Wire shapes are snake_case end to end. Each kind's own model is in its
13 * own file: folios-slides.ts, folios-design.ts, folios-dashboard.ts.
14 * `crates/contracts/src/folios.rs` mirrors what the Rust API needs, and a
15 * Rust test runs both validators over `folios.fixtures.json`.
16 *
17 * Access, in one place (section 2.1 of the plan):
18 *
19 * - Every folio has one owner, always a person, who has `manage`.
20 * - It sits in a space, or in its owner's Private section (`space` null).
21 * - A person's role is the highest of: owner; explicit grants on it or an
22 * ancestor it inherits from; their space role when it inherits up to a
23 * folio in a space; and the general access of its access root
24 * (`workspace`: every member; `link`: members who opened the link).
25 * - `inherit: false` makes a folio its own access root ("Only people
26 * invited"). "Private" (the lock) is computed: only the owner can read it.
27 * - An agent's role is never higher than its asker's, narrowed to what
28 * every person in the audience can read.
29 */
30import type { MemberProfile } from "./chat";
31import type { ServiceBinding } from "./clients";
32import type { DatasetQuery, DatasetResult } from "./datasets";
33import type {
34 DocAgentAbilities,
35 DocAgentMode,
36 DocBlockOutline,
37 DocDiffLine,
38 DocEditTarget,
39 DocRepoSpace,
40 DocRole,
41 DocSpace,
42 DocSpaceKind,
43 DocSuggestion,
44 DocThread,
45 DocThreadAction,
46} from "./docs";
47import type { DashboardOp, DashboardPreview } from "./folios-dashboard";
48import type { DesignOp, DesignPreview } from "./folios-design";
49import type { SlidesOp, SlidesPreview } from "./folios-slides";
50import type { User } from "./identity";
51import type { Result } from "./result";
52
53// ── Kinds ─────────────────────────────────────────────────────────────
54
55export type FolioKind = "doc" | "slides" | "design" | "dashboard";
56export const FOLIO_KINDS: readonly FolioKind[] = ["doc", "slides", "design", "dashboard"];
57
58/** The kinds as the Artifacts home's tiles name them. */
59export const FOLIO_KIND_LABELS: Record<FolioKind, string> = {
60 doc: "Docs",
61 slides: "Slides",
62 design: "Design",
63 dashboard: "Dashboard",
64};
65
66/** One of a kind, in a sentence: "a doc", "a deck"... */
67export const FOLIO_KIND_NOUNS: Record<FolioKind, string> = {
68 doc: "doc",
69 slides: "deck",
70 design: "design",
71 dashboard: "dashboard",
72};
73
74export function isFolioKind(value: unknown): value is FolioKind {
75 return typeof value === "string" && (FOLIO_KINDS as readonly string[]).includes(value);
76}
77
78/** Only a doc holds other folios: a doc with children is the folder. */
79export function folioCanHaveChildren(kind: FolioKind): boolean {
80 return kind === "doc";
81}
82
83// ── Roles and access ──────────────────────────────────────────────────
84
85/** What someone may do with a folio, weakest first. The same roles as a space's. */
86export type FolioRole = DocRole;
87export const FOLIO_ROLES: readonly FolioRole[] = ["view", "comment", "edit", "manage"];
88
89/** Who may open a folio besides its owner, grants and space. */
90export type FolioGeneralAccess = "none" | "workspace" | "link";
91export const FOLIO_GENERAL_ACCESS: readonly FolioGeneralAccess[] = ["none", "workspace", "link"];
92
93export const FOLIO_GENERAL_ACCESS_LABELS: Record<FolioGeneralAccess, string> = {
94 none: "Restricted",
95 workspace: "Everyone in the workspace",
96 link: "Anyone in the workspace with the link",
97};
98
99/** General access never gives `manage`. */
100export type FolioGeneralRole = Exclude<FolioRole, "manage">;
101export const FOLIO_GENERAL_ROLES: readonly FolioGeneralRole[] = ["view", "comment", "edit"];
102
103/** Space kinds as Artifacts names them (the database keeps workspace / team / private). */
104export const FOLIO_SPACE_KIND_LABELS: Record<DocSpaceKind, string> = {
105 workspace: "Open",
106 team: "Team",
107 private: "Members only",
108};
109
110/** Who a grant names: `user:<id>`, `agent:<id>` or `team:<slug>`. */
111export type FolioPrincipal = string;
112
113const PRINCIPAL = /^(user|agent|team):[A-Za-z0-9_.-]{1,64}$/;
114
115export function isFolioPrincipal(value: unknown): value is FolioPrincipal {
116 return typeof value === "string" && PRINCIPAL.test(value);
117}
118
119/** The deepest a folio tree goes. */
120export const FOLIO_MAX_DEPTH = 10;
121/** The longest title, in characters. */
122export const FOLIO_MAX_TITLE = 200;
123/** The most people a folio is shared with in one change. */
124export const FOLIO_MAX_SHARE = 50;
125/** A subtree larger than this has its access rebuilt by a queue job, not inline. */
126export const FOLIO_INLINE_REACL = 2_000;
127
128// ── Addresses ─────────────────────────────────────────────────────────
129
130const FOLIO_ID = /(fol_[0-9a-hjkmnp-tv-z]{26})$/;
131
132/** The words of a title as an address: `q4-roadmap`. At most 50 characters. */
133export function folioTitleSlug(title: string): string {
134 return title
135 .normalize("NFKD")
136 .replace(/[̀-ͯ]/g, "")
137 .toLowerCase()
138 .replace(/[^a-z0-9]+/g, "-")
139 .replace(/^-+|-+$/g, "")
140 .slice(0, 50)
141 .replace(/-+$/g, "");
142}
143
144/** A folio's last address segment, `<title-slug>-<id>`: only the id is read, so renames keep links working. */
145export function folioSlug(title: string, id: string): string {
146 const words = folioTitleSlug(title);
147 return words ? `${words}-${id}` : id;
148}
149
150/** A folio's address. Flat: moving it between spaces and Private never breaks a link. */
151export function folioPath(workspace: string, title: string, id: string): string {
152 return `/${workspace}/-/artifacts/${folioSlug(title, id)}`;
153}
154
155/** The folio id at the end of an address segment, or null. */
156export function folioIdFrom(segment: string | null | undefined): string | null {
157 return FOLIO_ID.exec(String(segment ?? ""))?.[1] ?? null;
158}
159
160/** Every folio id a text links to, for backlinks. */
161export function linkedFolioIds(text: string): string[] {
162 return [...new Set(text.match(/fol_[0-9a-hjkmnp-tv-z]{26}/g) ?? [])];
163}
164
165/** Segments the site's routes use under `-/artifacts/`: never a folio. */
166export const FOLIO_ROUTE_SEGMENTS: readonly string[] = [
167 "live",
168 "api",
169 "query",
170 "threads",
171 "upload",
172 "export",
173 "new",
174 "templates",
175 "trash",
176 "stale",
177 "spaces",
178 "repo",
179];
180
181// ── Folios ────────────────────────────────────────────────────────────
182
183/** Enough to link to a folio. */
184export type FolioRef = {
185 id: string;
186 kind: FolioKind;
187 title: string;
188 /** An emoji, or null for the kind's icon. */
189 icon: string | null;
190 /** `<title-slug>-<id>`. */
191 slug: string;
192 /** `/<ws>/-/artifacts/<slug>`. */
193 path: string;
194};
195
196/** The space a folio sits in. */
197export type FolioSpaceRef = { id: string; slug: string; name: string; kind: DocSpaceKind };
198
199/** Where a folio's access comes from when it inherits. */
200export type FolioInheritedFrom = { kind: "space" | "folio"; id: string; name: string };
201
202/** What a card draws: written when the folio is saved, never data values. */
203export type FolioPreview = { kind: "doc"; lines: string[] } | SlidesPreview | DesignPreview | DashboardPreview;
204
205/** A folio as lists and its page show it, for one viewer. */
206export type Folio = FolioRef & {
207 workspace_id: string;
208 /** Null: its owner's Private section. */
209 space: FolioSpaceRef | null;
210 parent_id: string | null;
211 position: number;
212 owner: MemberProfile;
213 created_by: MemberProfile;
214 created_at: string;
215 /** Any change: rename, move, share. */
216 updated_at: string;
217 /** Content changes: "Edited 45m ago". */
218 edited_by: MemberProfile | null;
219 edited_at: string;
220 trashed_at: string | null;
221 viewer_role: FolioRole;
222 favorite: boolean;
223 /** Only its owner can read it (the lock). */
224 private: boolean;
225 /** How many people, agents and teams it is shared with directly. */
226 shared_count: number;
227 /** The access root's general access. */
228 general_access: FolioGeneralAccess;
229 general_role: FolioGeneralRole | null;
230 /** False: "Only people invited", its own access root. */
231 inherit: boolean;
232 inherited_from: FolioInheritedFrom | null;
233 /** How agents change it: its own, else its space's, else `suggest`. */
234 agent_mode: DocAgentMode;
235 excerpt: string;
236 preview: FolioPreview | null;
237 /** Where it was written up from, such as a chat thread, when it was. */
238 source: { title: string; href: string } | null;
239 /** Possibly out of date: code it cites changed. */
240 stale: boolean;
241 has_children: boolean;
242};
243
244/** A row of the sidebar's trees. */
245export type FolioTreeNode = {
246 id: string;
247 kind: FolioKind;
248 parent_id: string | null;
249 position: number;
250 title: string;
251 icon: string | null;
252 slug: string;
253 /** It restricts access below where it sits (a lock on the row). */
254 restricted: boolean;
255 stale?: boolean;
256};
257
258export type FolioListTab = "all" | "yours" | "shared";
259export const FOLIO_LIST_TABS: readonly FolioListTab[] = ["all", "yours", "shared"];
260
261export type FolioListQuery = {
262 tab: FolioListTab;
263 kinds?: FolioKind[] | null;
264 space_id?: string | null;
265 /** A member key, `user:<id>`. */
266 owner?: string | null;
267 /** `owner/name`. */
268 project?: string | null;
269 /** Words or meaning; hybrid search when given. */
270 q?: string | null;
271 cursor?: string | null;
272 limit?: number | null;
273};
274
275/** The most folios a page of a list holds. */
276export const FOLIO_LIST_MAX = 100;
277
278export type FolioList = { items: Folio[]; next_cursor: string | null };
279
280/** A folio as its own page opens it: the folio, its saved text, where it sits, what is in it and what links to it. */
281export type FolioPage = {
282 folio: Folio;
283 /** Its text rendition when last saved (a doc's Markdown): what shows until the live editor loads. */
284 text: string;
285 /**
286 * The document as last saved (a Yjs update, base64), so the editor
287 * opens from it at once and syncs the difference with its room; null
288 * when none is kept yet or it is too large to carry, and the editor
289 * waits for the room.
290 */
291 state: string | null;
292 /** The docs it sits under, from the top, that the viewer can read. */
293 breadcrumbs: FolioRef[];
294 /** What sits under it (a doc's sub-pages) that the viewer can read. */
295 children: FolioRef[];
296 /** Folios the viewer can read that link to it. */
297 backlinks: FolioRef[];
298 /** A doc's open suggestions. */
299 suggestions: FolioSuggestion[];
300};
301
302export type FoliosSidebarSpace = DocSpace & { joined: boolean; tree: FolioTreeNode[] };
303
304export type FoliosSidebar = {
305 favorites: FolioRef[];
306 /** Joined open spaces, the viewer's team spaces and Members-only spaces. */
307 spaces: FoliosSidebarSpace[];
308 /** The viewer's own folios in no space. */
309 private_tree: FolioTreeNode[];
310 /** The tops of what is shared with the viewer: the highest ancestor they can read. */
311 shared: FolioRef[];
312 /** Projects' docs: repositories' `docs/` folders, read-only. */
313 repos: DocRepoSpace[];
314 can_create_space: boolean;
315 trash_count: number;
316 stale_count: number;
317};
318
319/** Content to start a folio with: Markdown for docs and slides, a spec for designs and dashboards. */
320export type FolioContentInput = { markdown: string } | { spec: unknown };
321
322export type NewFolio = {
323 kind: FolioKind;
324 title?: string | null;
325 icon?: string | null;
326 /** Null or absent: the creator's Private section. */
327 space_id?: string | null;
328 /** A doc to sit under. */
329 parent_id?: string | null;
330 template_id?: string | null;
331 content?: FolioContentInput | null;
332 source?: { title: string; href: string } | null;
333 share_with?: { principal: FolioPrincipal; role: FolioRole }[] | null;
334};
335
336export type FolioChange = {
337 title?: string;
338 icon?: string | null;
339 cover?: string | null;
340 projects?: string[];
341};
342
343/** Where to put a folio: a space (null: Private) and a parent doc, before a sibling or last. */
344export type FolioMove = { space_id: string | null; parent_id: string | null; before_id?: string | null };
345
346// ── Sharing ───────────────────────────────────────────────────────────
347
348/** Where a row of "Who has access" comes from. */
349export type FolioAccessSource =
350 | { kind: "owner" }
351 /** A grant on this folio: editable here. */
352 | { kind: "grant" }
353 /** A grant on a parent it inherits from: change it there. */
354 | { kind: "folio"; id: string; title: string; path: string }
355 /** The space it inherits from. */
356 | { kind: "space"; id: string; name: string };
357
358export type FolioAccessRow = {
359 principal: FolioPrincipal;
360 /** A person or agent; a team is shown by name. */
361 profile: MemberProfile | { kind: "team"; id: string; name: string; display_name: string };
362 role: FolioRole;
363 source: FolioAccessSource;
364};
365
366/** The share dialog. */
367export type FolioAccessList = {
368 folio_id: string;
369 owner: MemberProfile;
370 rows: FolioAccessRow[];
371 general_access: FolioGeneralAccess;
372 general_role: FolioGeneralRole | null;
373 inherit: boolean;
374 inherited_from: FolioInheritedFrom | null;
375 /** The folio's own setting; null follows its space's. */
376 agent_mode: DocAgentMode | null;
377 /** The viewer may change any of it. */
378 can_share: boolean;
379 /** Public links are off until a workspace turns them on (later). */
380 public_link: "off";
381};
382
383/** One change from the share dialog. */
384export type FolioAccessChange =
385 /** Share with someone, or change their role; `notify` is an optional message. */
386 | { op: "grant"; principal: FolioPrincipal; role: FolioRole; notify?: string | null }
387 | { op: "revoke"; principal: FolioPrincipal }
388 /** `none` takes no role; `workspace` and `link` take one, never `manage`. */
389 | { op: "general"; access: FolioGeneralAccess; role: FolioGeneralRole | null }
390 /** Follow the space or parent (true), or "Only people invited" (false). */
391 | { op: "inherit"; inherit: boolean }
392 | { op: "agent_mode"; agent_mode: DocAgentMode | null };
393
394// ── History, proposals, templates ─────────────────────────────────────
395
396export type FolioVersionKind = "created" | "edit" | "agent" | "suggestion" | "proposal" | "restore";
397
398export type FolioVersion = {
399 id: string;
400 folio_id: string;
401 created_at: string;
402 authors: MemberProfile[];
403 kind: FolioVersionKind;
404 note: string | null;
405};
406
407export type FolioVersionDetail = FolioVersion & {
408 /** The folio's text rendition then. */
409 text: string;
410 /** Against the version before it. */
411 diff: DocDiffLine[];
412};
413
414export type FolioProposalStatus = "open" | "accepted" | "rejected" | "stale";
415
416/** An agent's whole change to a slides deck, design or dashboard, which a person previews and applies or rejects. */
417export type FolioProposal = {
418 id: string;
419 folio_id: string;
420 author: MemberProfile;
421 asked_by: MemberProfile | null;
422 note: string | null;
423 /** "Adds slides 4–6; rewrites the title slide". */
424 summary: string;
425 status: FolioProposalStatus;
426 created_at: string;
427 decided_by: MemberProfile | null;
428 decided_at: string | null;
429};
430
431/** A doc's tracked change, as Docs has them, on a folio. */
432export type FolioSuggestion = Omit<DocSuggestion, "page_id"> & { folio_id: string };
433
434export type FolioTemplate = {
435 id: string;
436 kind: FolioKind;
437 name: string;
438 description: string;
439 icon: string | null;
440 builtin: boolean;
441 /** Markdown (doc, slides) or a JSON spec (design, dashboard). */
442 body: string;
443 created_by: MemberProfile | null;
444};
445
446// ── Live ──────────────────────────────────────────────────────────────
447
448/** JSON text frames on a folio's socket, besides the Yjs protocol's binary ones. */
449export type FoliosLiveEvent =
450 | { type: "folio.updated"; folio: Folio }
451 | { type: "folio.trashed"; folio_id: string }
452 | { type: "suggestion.created" | "suggestion.updated"; suggestion: FolioSuggestion }
453 | { type: "proposal.created" | "proposal.updated"; proposal: FolioProposal }
454 | { type: "version.created"; version: FolioVersion }
455 /** Whether it is possibly out of date changed: ask again. */
456 | { type: "folio.staleness" }
457 /** Its sharing changed: ask for the access list again to refresh badges. */
458 | { type: "folio.access" }
459 /** The viewer's role changed, or their access ended (`role` null; the socket then closes with 4403). */
460 | { type: "access"; role: FolioRole | null };
461
462// ── Agents ────────────────────────────────────────────────────────────
463
464export type FolioAgentAbilities = DocAgentAbilities;
465
466/** Options on any agent edit. */
467export type FolioEditOptions = {
468 note?: string | null;
469 /** Never edit directly: file a suggestion (doc) or a proposal (others). */
470 suggest_only?: boolean | null;
471 /** The edit brings it up to date with the code it cites. */
472 marks_current?: boolean | null;
473};
474
475/** An agent's (or a token's) change to a folio, in the kind's own terms. */
476export type FolioAgentEdit = (
477 | { kind: "doc"; target: DocEditTarget; markdown: string }
478 | { kind: "slides"; ops: SlidesOp[] }
479 | { kind: "design"; ops: DesignOp[] }
480 | { kind: "dashboard"; ops: DashboardOp[] }
481) &
482 FolioEditOptions;
483
484/** The most ops one edit carries. */
485export const FOLIO_MAX_OPS = 200;
486
487export type FolioAgentEditResult =
488 | { mode: "applied"; version_id: string | null; folio: FolioRef; summary: string }
489 | { mode: "suggested"; suggestion: FolioSuggestion; folio: FolioRef }
490 | { mode: "proposed"; proposal: FolioProposal; folio: FolioRef };
491
492/**
493 * A folio in the form an agent reads and writes: a doc's Markdown with its
494 * block ids; a deck's Markdown with slide ids; a design's node spec; a
495 * dashboard's spec (tiles and queries, never values).
496 */
497export type FolioAgentRead = {
498 folio: FolioRef & { edited_at: string };
499 space: { id: string; slug: string; name: string; agent_mode: DocAgentMode } | null;
500 /** Markdown for doc and slides; JSON text for design and dashboard. */
501 content: string;
502 /** doc: top-level blocks, for `blocks` targets. */
503 blocks?: DocBlockOutline[];
504 can: FolioAgentAbilities;
505 /** False: someone the agent is talking to can't read it, so don't quote it there. */
506 audience_can_read: boolean;
507};
508
509export type FolioSearchHit = FolioRef & {
510 space_name: string | null;
511 snippet: string;
512 edited_at: string;
513 heading?: string | null;
514 matched?: "words" | "meaning" | "both" | null;
515};
516
517// ── Validators (pure; mirrored in Rust) ───────────────────────────────
518
519function isObject(value: unknown): value is Record<string, unknown> {
520 return typeof value === "object" && value !== null && !Array.isArray(value);
521}
522
523const isRole = (value: unknown): value is FolioRole => typeof value === "string" && (FOLIO_ROLES as readonly string[]).includes(value);
524
525/** What is wrong with a new folio, or null. Whether its space and parent exist and allow it is the service's to say. */
526export function newFolioError(input: NewFolio): string | null {
527 if (!isFolioKind(input.kind)) return `There is no kind of artifact called ${String(input.kind)}.`;
528 const title = input.title ?? "";
529 if ([...title].length > FOLIO_MAX_TITLE) return `A title is at most ${FOLIO_MAX_TITLE} characters.`;
530 const content = input.content ?? null;
531 if (content !== null) {
532 const wantsMarkdown = input.kind === "doc" || input.kind === "slides";
533 if (wantsMarkdown && typeof (content as { markdown?: unknown }).markdown !== "string") return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from markdown.`;
534 if (!wantsMarkdown && !isObject((content as { spec?: unknown }).spec)) return `A ${FOLIO_KIND_NOUNS[input.kind]} starts from a spec.`;
535 if (input.template_id != null) return "Start from a template or from content, not both.";
536 }
537 const share = input.share_with ?? [];
538 if (share.length > FOLIO_MAX_SHARE) return `Share with at most ${FOLIO_MAX_SHARE} at once.`;
539 for (const row of share) {
540 if (!isFolioPrincipal(row.principal)) return `${String(row.principal)} is not user:, agent: or team: and an id.`;
541 if (!isRole(row.role)) return `There is no role called ${String(row.role)}.`;
542 }
543 return null;
544}
545
546/** What is wrong with a change from the share dialog, or null. */
547export function folioAccessChangeError(change: FolioAccessChange): string | null {
548 switch (change.op) {
549 case "grant":
550 if (!isFolioPrincipal(change.principal)) return `${String(change.principal)} is not user:, agent: or team: and an id.`;
551 if (!isRole(change.role)) return `There is no role called ${String(change.role)}.`;
552 if (change.notify != null && [...change.notify].length > 2_000) return "A message is at most 2000 characters.";
553 return null;
554 case "revoke":
555 return isFolioPrincipal(change.principal) ? null : `${String(change.principal)} is not user:, agent: or team: and an id.`;
556 case "general":
557 if (!(FOLIO_GENERAL_ACCESS as readonly string[]).includes(change.access)) return `There is no general access called ${String(change.access)}.`;
558 if (change.access === "none") return change.role === null ? null : "Restricted takes no role.";
559 if (change.role === null) return `${FOLIO_GENERAL_ACCESS_LABELS[change.access]} needs a role.`;
560 return (FOLIO_GENERAL_ROLES as readonly string[]).includes(change.role) ? null : "General access gives view, comment or edit, never full access.";
561 case "inherit":
562 return typeof change.inherit === "boolean" ? null : "inherit is true or false.";
563 case "agent_mode":
564 return change.agent_mode === null || change.agent_mode === "suggest" || change.agent_mode === "edit" ? null : "agent_mode is suggest, edit or null.";
565 default:
566 return `There is no access change called ${String((change as { op?: unknown }).op)}.`;
567 }
568}
569
570/**
571 * What is wrong with an agent edit's envelope, or null: its kind, and a
572 * doc's target and Markdown or the other kinds' list of ops. Each op is
573 * checked by its kind's validator (`slidesOpError`, `designOpError`,
574 * `dashboardOpError`).
575 */
576export function folioAgentEditError(edit: unknown): string | null {
577 if (!isObject(edit)) return "An edit is an object.";
578 if (!isFolioKind(edit.kind)) return `There is no kind of artifact called ${String(edit.kind)}.`;
579 if (edit.note != null && typeof edit.note !== "string") return "note is text.";
580 if (edit.kind === "doc") {
581 if (typeof edit.markdown !== "string") return "A doc edit has markdown.";
582 if (edit.ops !== undefined) return "A doc edit has a target and markdown, not ops.";
583 const target = edit.target;
584 if (!isObject(target)) return "A doc edit has a target.";
585 switch (target.kind) {
586 case "append":
587 case "document":
588 return null;
589 case "section":
590 return typeof target.heading === "string" && target.heading.length > 0 ? null : "A section target names its heading.";
591 case "blocks":
592 return typeof target.from_block === "string" && typeof target.to_block === "string" ? null : "A blocks target names from_block and to_block.";
593 default:
594 return "A doc edit's target is append, document, section or blocks.";
595 }
596 }
597 if (edit.markdown !== undefined || edit.target !== undefined) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has ops, not a target and markdown.`;
598 if (!Array.isArray(edit.ops) || edit.ops.length === 0) return `A ${FOLIO_KIND_NOUNS[edit.kind]} edit has a list of ops.`;
599 if (edit.ops.length > FOLIO_MAX_OPS) return `An edit has at most ${FOLIO_MAX_OPS} ops.`;
600 return edit.ops.every((op) => isObject(op) && typeof op.op === "string") ? null : "Each op is an object with an op.";
601}
602
603/** What is wrong with a list query, or null. */
604export function folioListQueryError(query: FolioListQuery): string | null {
605 if (!(FOLIO_LIST_TABS as readonly string[]).includes(query.tab)) return "tab is all, yours or shared.";
606 for (const kind of query.kinds ?? []) if (!isFolioKind(kind)) return `There is no kind of artifact called ${String(kind)}.`;
607 const limit = query.limit ?? null;
608 if (limit !== null && (!Number.isInteger(limit) || limit < 1 || limit > FOLIO_LIST_MAX)) return `limit is between 1 and ${FOLIO_LIST_MAX}.`;
609 return null;
610}
611
612// ── The artifacts service's folio RPC ──────────────────────────────────────
613
614/** Every method the artifacts service answers for folios at `/rpc/<method>` (plan section 7). */
615export const FOLIO_RPC_METHODS = [
616 // Lists and navigation.
617 "folio_list",
618 "folio_sidebar",
619 "folio",
620 "folio_page",
621 // Changing folios.
622 "create_folio",
623 "update_folio",
624 "move_folio",
625 "duplicate_folio",
626 "trash_folio",
627 "restore_folio",
628 "delete_folio",
629 "folio_trash",
630 "empty_folio_trash",
631 "favorite_folio",
632 // A person's (or a token's) own read and edit in the agent form.
633 "folio_content",
634 "edit_folio",
635 // Sharing and spaces.
636 "folio_access",
637 "set_folio_grant",
638 "set_folio_general_access",
639 "request_folio_access",
640 "join_space",
641 "leave_space",
642 "delete_space",
643 // Search, history, templates and export.
644 "search_folios",
645 "folio_versions",
646 "folio_version",
647 "restore_folio_version",
648 "folio_templates",
649 "save_folio_template",
650 "delete_folio_template",
651 "export_folio",
652 // Suggestions, proposals and comments.
653 "folio_suggestions",
654 "decide_folio_suggestion",
655 "folio_proposals",
656 "decide_folio_proposal",
657 "folio_thread",
658 "folio_threads",
659 // Dashboards.
660 "query_tile",
661 "query_dataset",
662 // Staleness and the search index.
663 "mark_folio_current",
664 "reindex_folios",
665] as const;
666
667export type FolioRpcMethod = (typeof FOLIO_RPC_METHODS)[number];
668
669export type FoliosApi = {
670 // ── The site, and the API acting for a person ───────────────────────
671 list(workspace: string, viewer: User, query: FolioListQuery): Promise<Result<FolioList>>;
672 sidebar(workspace: string, viewer: User): Promise<Result<FoliosSidebar>>;
673 /**
674 * A folio and the viewer's role in it; records the visit (which is what
675 * makes a link folio readable). Not found when they can't read it. A
676 * `peek` (chat's card for a link) records nothing, so it finds a link
677 * folio only once they have opened it, and never one in the trash.
678 */
679 folio(workspace: string, viewer: User, folioId: string, options?: { peek?: boolean }): Promise<Result<Folio>>;
680 /** As `folio`, with what its page shows around it. Records the visit too. */
681 page(workspace: string, viewer: User, folioId: string): Promise<Result<FolioPage>>;
682 create(workspace: string, viewer: User, input: NewFolio): Promise<Result<Folio>>;
683 update(workspace: string, viewer: User, folioId: string, change: FolioChange): Promise<Result<Folio>>;
684 /** Edit role where it is and where it goes. Refuses moving under itself, under a non-doc, or deeper than `FOLIO_MAX_DEPTH`. */
685 move(workspace: string, viewer: User, folioId: string, move: FolioMove): Promise<Result<Folio>>;
686 duplicate(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
687 /** To the trash, with everything under it. */
688 trash(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
689 restore(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
690 /** For good: only from the trash, manage role. */
691 delete(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
692 /** What is in the trash that the viewer may restore: the tops of what went there together. */
693 trashed(workspace: string, viewer: User): Promise<Result<Folio[]>>;
694 /**
695 * Deletes for good everything in the trash that the viewer has manage
696 * role on (what `trashed` lists for them with full access), with
697 * everything under it. What they may only edit stays. `deleted` counts
698 * the tops that went.
699 */
700 emptyTrash(workspace: string, viewer: User): Promise<Result<{ deleted: number }>>;
701 favorite(workspace: string, viewer: User, folioId: string, on: boolean): Promise<Result<boolean>>;
702 /** The folio in its agent form, for a person or their token. */
703 content(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAgentRead>>;
704 /** Applies an edit in the kind's terms as the viewer: edit role, else a suggestion or proposal with comment role. */
705 edit(workspace: string, viewer: User, folioId: string, edit: FolioAgentEdit): Promise<Result<FolioAgentEditResult>>;
706
707 access(workspace: string, viewer: User, folioId: string): Promise<Result<FolioAccessList>>;
708 /** One change from the share dialog: grants go to `set_folio_grant`, the rest to `set_folio_general_access`. */
709 changeAccess(workspace: string, viewer: User, folioId: string, change: FolioAccessChange): Promise<Result<FolioAccessList>>;
710 /** Asks the owner and managers for access; they get an inbox item. */
711 requestAccess(workspace: string, viewer: User, folioId: string, message?: string | null): Promise<Result<boolean>>;
712 joinSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
713 leaveSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<boolean>>;
714 /**
715 * Deletes a space, the General space included: everything in it goes to
716 * the trash (`trashed` counts the artifacts), where whoever could edit
717 * it restores it as usual, and the space itself is gone for good. Full
718 * access to the space, or a workspace owner. A workspace may end up with
719 * no spaces; General is never made again once deleted.
720 */
721 deleteSpace(workspace: string, viewer: User, spaceId: string): Promise<Result<{ trashed: number }>>;
722
723 search(
724 workspace: string,
725 viewer: User,
726 query: {
727 q: string;
728 kinds?: FolioKind[] | null;
729 space_id?: string | null;
730 project?: string | null;
731 owner?: string | null;
732 mode?: "words" | "hybrid" | null;
733 limit?: number | null;
734 },
735 ): Promise<Result<FolioSearchHit[]>>;
736 versions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioVersion[]>>;
737 version(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersionDetail>>;
738 restoreVersion(workspace: string, viewer: User, folioId: string, versionId: string): Promise<Result<FolioVersion>>;
739 templates(workspace: string, viewer: User, kind?: FolioKind | null): Promise<Result<FolioTemplate[]>>;
740 saveTemplate(workspace: string, viewer: User, input: { folio_id: string; name: string; description?: string | null }): Promise<Result<FolioTemplate>>;
741 deleteTemplate(workspace: string, viewer: User, templateId: string): Promise<Result<boolean>>;
742 export(
743 workspace: string,
744 viewer: User,
745 folioId: string,
746 format?: "markdown" | "json" | null,
747 ): Promise<Result<{ filename: string; content_type: string; body: string }>>;
748
749 suggestions(workspace: string, viewer: User, folioId: string): Promise<Result<FolioSuggestion[]>>;
750 decideSuggestion(workspace: string, viewer: User, suggestionId: string, decision: "accept" | "reject"): Promise<Result<FolioSuggestion>>;
751 proposals(workspace: string, viewer: User, folioId: string): Promise<Result<FolioProposal[]>>;
752 decideProposal(workspace: string, viewer: User, proposalId: string, decision: "accept" | "reject"): Promise<Result<FolioProposal>>;
753 thread(workspace: string, viewer: User, folioId: string, action: DocThreadAction): Promise<Result<unknown>>;
754 threads(workspace: string, viewer: User, folioId: string): Promise<Result<DocThread[]>>;
755
756 /** A dashboard tile's numbers for the viewer: the tile's query, read from the room. */
757 queryTile(workspace: string, viewer: User, folioId: string, tileId: string): Promise<Result<DatasetResult>>;
758 /** A query as the viewer, for the API. */
759 queryDataset(workspace: string, viewer: User, query: DatasetQuery): Promise<Result<DatasetResult>>;
760
761 markCurrent(workspace: string, viewer: User, folioId: string): Promise<Result<boolean>>;
762 /** Indexes the workspace's folios again in the background. Workspace owners. */
763 reindex(workspace: string, viewer: User): Promise<Result<boolean>>;
764};
765
766export function foliosClient(service: ServiceBinding): FoliosApi {
767 const call = async <T>(method: FolioRpcMethod, args: object): Promise<T> => {
768 const response = await service.fetch(`https://service/rpc/${method}`, {
769 method: "POST",
770 headers: { "content-type": "application/json" },
771 body: JSON.stringify(args),
772 });
773 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
774 return (await response.json()) as T;
775 };
776 return {
777 list: (workspace, viewer, query) => call("folio_list", { workspace, viewer, query }),
778 sidebar: (workspace, viewer) => call("folio_sidebar", { workspace, viewer }),
779 folio: (workspace, viewer, folioId, options) => call("folio", { workspace, viewer, folio_id: folioId, ...(options?.peek ? { peek: true } : {}) }),
780 page: (workspace, viewer, folioId) => call("folio_page", { workspace, viewer, folio_id: folioId }),
781 create: (workspace, viewer, input) => call("create_folio", { workspace, viewer, input }),
782 update: (workspace, viewer, folioId, change) => call("update_folio", { workspace, viewer, folio_id: folioId, change }),
783 move: (workspace, viewer, folioId, move) => call("move_folio", { workspace, viewer, folio_id: folioId, move }),
784 duplicate: (workspace, viewer, folioId) => call("duplicate_folio", { workspace, viewer, folio_id: folioId }),
785 trash: (workspace, viewer, folioId) => call("trash_folio", { workspace, viewer, folio_id: folioId }),
786 restore: (workspace, viewer, folioId) => call("restore_folio", { workspace, viewer, folio_id: folioId }),
787 delete: (workspace, viewer, folioId) => call("delete_folio", { workspace, viewer, folio_id: folioId }),
788 trashed: (workspace, viewer) => call("folio_trash", { workspace, viewer }),
789 emptyTrash: (workspace, viewer) => call("empty_folio_trash", { workspace, viewer }),
790 favorite: (workspace, viewer, folioId, on) => call("favorite_folio", { workspace, viewer, folio_id: folioId, on }),
791 content: (workspace, viewer, folioId) => call("folio_content", { workspace, viewer, folio_id: folioId }),
792 edit: (workspace, viewer, folioId, edit) => call("edit_folio", { workspace, viewer, folio_id: folioId, edit }),
793 access: (workspace, viewer, folioId) => call("folio_access", { workspace, viewer, folio_id: folioId }),
794 changeAccess: (workspace, viewer, folioId, change) =>
795 call(change.op === "grant" || change.op === "revoke" ? "set_folio_grant" : "set_folio_general_access", { workspace, viewer, folio_id: folioId, change }),
796 requestAccess: (workspace, viewer, folioId, message) => call("request_folio_access", { workspace, viewer, folio_id: folioId, message: message ?? null }),
797 joinSpace: (workspace, viewer, spaceId) => call("join_space", { workspace, viewer, space_id: spaceId }),
798 leaveSpace: (workspace, viewer, spaceId) => call("leave_space", { workspace, viewer, space_id: spaceId }),
799 deleteSpace: (workspace, viewer, spaceId) => call("delete_space", { workspace, viewer, space_id: spaceId }),
800 search: (workspace, viewer, query) => call("search_folios", { workspace, viewer, query }),
801 versions: (workspace, viewer, folioId) => call("folio_versions", { workspace, viewer, folio_id: folioId }),
802 version: (workspace, viewer, folioId, versionId) => call("folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
803 restoreVersion: (workspace, viewer, folioId, versionId) => call("restore_folio_version", { workspace, viewer, folio_id: folioId, version_id: versionId }),
804 templates: (workspace, viewer, kind) => call("folio_templates", { workspace, viewer, kind: kind ?? null }),
805 saveTemplate: (workspace, viewer, input) => call("save_folio_template", { workspace, viewer, input }),
806 deleteTemplate: (workspace, viewer, templateId) => call("delete_folio_template", { workspace, viewer, template_id: templateId }),
807 export: (workspace, viewer, folioId, format) => call("export_folio", { workspace, viewer, folio_id: folioId, format: format ?? null }),
808 suggestions: (workspace, viewer, folioId) => call("folio_suggestions", { workspace, viewer, folio_id: folioId }),
809 decideSuggestion: (workspace, viewer, suggestionId, decision) =>
810 call("decide_folio_suggestion", { workspace, viewer, suggestion_id: suggestionId, decision }),
811 proposals: (workspace, viewer, folioId) => call("folio_proposals", { workspace, viewer, folio_id: folioId }),
812 decideProposal: (workspace, viewer, proposalId, decision) => call("decide_folio_proposal", { workspace, viewer, proposal_id: proposalId, decision }),
813 thread: (workspace, viewer, folioId, action) => call("folio_thread", { workspace, viewer, folio_id: folioId, action }),
814 threads: (workspace, viewer, folioId) => call("folio_threads", { workspace, viewer, folio_id: folioId }),
815 queryTile: (workspace, viewer, folioId, tileId) => call("query_tile", { workspace, viewer, folio_id: folioId, tile_id: tileId }),
816 queryDataset: (workspace, viewer, query) => call("query_dataset", { workspace, viewer, query }),
817 markCurrent: (workspace, viewer, folioId) => call("mark_folio_current", { workspace, viewer, folio_id: folioId }),
818 reindex: (workspace, viewer) => call("reindex_folios", { workspace, viewer }),
819 };
820}