Skip to content

Commit

The docs service answers folio_page with a folio and what its page shows around it, a space can let its editors share what is in it, and someone asking for access to an artifact is told to wait when they already asked for it in the last day.

Migration 0005_space_sharing_and_access_requests adds spaces.editors_can_share (off by default) and folio_access_requests. Editors who may share give up to edit and never change someone's full access. Access requests still go to at most 20 people. Projects' docs files link under -/artifacts/repo.

syntaqxcommitted Parent6c97c24Browse files
10 files+185−50/10 viewed
+2−1
498498
499499 /// Every method the docs service answers for folios, as `FOLIO_RPC_METHODS`
500500 /// in folios.ts.
501−pub const FOLIO_RPC_METHODS: [&str; 46] = [
501+pub const FOLIO_RPC_METHODS: [&str; 47] = [
502502 "folio_list",
503503 "folio_sidebar",
504504 "folio",
505+ "folio_page",
505506 "create_folio",
506507 "update_folio",
507508 "move_folio",
+3−0
8383 /** What every workspace member (workspace) or team member (team) gets; null for a private space. */
8484 default_role: DocRole | null;
8585 agent_mode: DocAgentMode;
86+ /** People with edit access may share what is in it (Artifacts), as people with full access can. Off unless turned on. */
87+ editors_can_share: boolean;
8688 /** The workspace's General space, made the first time Docs is opened. Can't be archived. */
8789 is_default: boolean;
8890 /** Projects (repositories, `owner/name`) the space is about: Docs filters by them. */
454456 default_role?: DocRole | null;
455457 agent_mode?: DocAgentMode | null;
456458 projects?: string[] | null;
459+ editors_can_share?: boolean | null;
457460 };
458461
459462 export type DocSpaceChange = Partial<Omit<NewDocSpace, "kind">> & { kind?: DocSpaceKind; archived?: boolean };
+19−0
266266
267267 export type FolioList = { items: Folio[]; next_cursor: string | null };
268268
269+/** 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. */
270+export type FolioPage = {
271+ folio: Folio;
272+ /** Its text rendition when last saved (a doc's Markdown): what shows until the live editor loads. */
273+ text: string;
274+ /** The docs it sits under, from the top, that the viewer can read. */
275+ breadcrumbs: FolioRef[];
276+ /** What sits under it (a doc's sub-pages) that the viewer can read. */
277+ children: FolioRef[];
278+ /** Folios the viewer can read that link to it. */
279+ backlinks: FolioRef[];
280+ /** A doc's open suggestions. */
281+ suggestions: FolioSuggestion[];
282+};
283+
269284 export type FoliosSidebarSpace = DocSpace & { joined: boolean; tree: FolioTreeNode[] };
270285
271286 export type FoliosSidebar = {
599614 "folio_list",
600615 "folio_sidebar",
601616 "folio",
617+ "folio_page",
602618 // Changing folios.
603619 "create_folio",
604620 "update_folio",
659675 sidebar(workspace: string, viewer: User): Promise<Result<FoliosSidebar>>;
660676 /** A folio and the viewer's role in it; records the visit (which is what makes a link folio readable). Not found when they can't read it. */
661677 folio(workspace: string, viewer: User, folioId: string): Promise<Result<Folio>>;
678+ /** As `folio`, with what its page shows around it. Records the visit too. */
679+ page(workspace: string, viewer: User, folioId: string): Promise<Result<FolioPage>>;
662680 create(workspace: string, viewer: User, input: NewFolio): Promise<Result<Folio>>;
663681 update(workspace: string, viewer: User, folioId: string, change: FolioChange): Promise<Result<Folio>>;
664682 /** Edit role where it is and where it goes. Refuses moving under itself, under a non-doc, or deeper than `FOLIO_MAX_DEPTH`. */
750768 list: (workspace, viewer, query) => call("folio_list", { workspace, viewer, query }),
751769 sidebar: (workspace, viewer) => call("folio_sidebar", { workspace, viewer }),
752770 folio: (workspace, viewer, folioId) => call("folio", { workspace, viewer, folio_id: folioId }),
771+ page: (workspace, viewer, folioId) => call("folio_page", { workspace, viewer, folio_id: folioId }),
753772 create: (workspace, viewer, input) => call("create_folio", { workspace, viewer, input }),
754773 update: (workspace, viewer, folioId, change) => call("update_folio", { workspace, viewer, folio_id: folioId, change }),
755774 move: (workspace, viewer, folioId, move) => call("move_folio", { workspace, viewer, folio_id: folioId, move }),
+15−0
1+-- Artifacts mode, Phase 2 (docs/ARTIFACTS_MODE.md section 12).
2+--
3+-- "Editors can share": a space setting that lets people with edit access
4+-- share what is in the space, as people with full access can. Off unless
5+-- a space's managers turn it on.
6+ALTER TABLE spaces ADD COLUMN editors_can_share INTEGER NOT NULL DEFAULT 0;
7+
8+-- Access requests, one row per person and folio: when they last asked.
9+-- A person asks again for the same folio at most once a day.
10+CREATE TABLE folio_access_requests (
11+ folio_id TEXT NOT NULL REFERENCES folios (id) ON DELETE CASCADE,
12+ user_id TEXT NOT NULL,
13+ requested_at TEXT NOT NULL,
14+ PRIMARY KEY (folio_id, user_id)
15+);
+53−0
1+import assert from "node:assert/strict";
2+import { readFileSync, readdirSync } from "node:fs";
3+import { DatabaseSync } from "node:sqlite";
4+import { test } from "node:test";
5+
6+import { REQUEST_EVERY_MS, claimAccessRequest } from "./requests.ts";
7+
8+/** D1, as far as requests use it, over node's SQLite with the service's migrations. */
9+function fakeD1(): D1Database {
10+ const db = new DatabaseSync(":memory:");
11+ const dir = new URL("../../migrations/", import.meta.url);
12+ for (const file of readdirSync(dir).sort()) db.exec(readFileSync(new URL(file, dir), "utf8"));
13+ db.exec("PRAGMA foreign_keys = OFF");
14+ const statement = (sql: string, params: unknown[] = []) => ({
15+ bind: (...values: unknown[]) => statement(sql, values),
16+ first: async () => (db.prepare(sql).get(...(params as never[])) as unknown) ?? null,
17+ run: async () => db.prepare(sql).run(...(params as never[])),
18+ });
19+ return { prepare: (sql: string) => statement(sql) } as unknown as D1Database;
20+}
21+
22+const at = new Date("2026-10-09T12:00:00.000Z");
23+const later = (ms: number) => new Date(at.getTime() + ms);
24+
25+test("a person's first request for a folio goes", async () => {
26+ const db = fakeD1();
27+ assert.equal(await claimAccessRequest(db, "fol_a", "ana", at), true);
28+});
29+
30+test("asking again within a day is refused, for that folio only", async () => {
31+ const db = fakeD1();
32+ await claimAccessRequest(db, "fol_a", "ana", at);
33+ assert.equal(await claimAccessRequest(db, "fol_a", "ana", later(60_000)), false);
34+ assert.equal(await claimAccessRequest(db, "fol_a", "ana", later(REQUEST_EVERY_MS - 1)), false);
35+ // Someone else, or another folio, is a request of its own.
36+ assert.equal(await claimAccessRequest(db, "fol_a", "bo", later(60_000)), true);
37+ assert.equal(await claimAccessRequest(db, "fol_b", "ana", later(60_000)), true);
38+});
39+
40+test("a day later they may ask again, and the day starts over", async () => {
41+ const db = fakeD1();
42+ await claimAccessRequest(db, "fol_a", "ana", at);
43+ assert.equal(await claimAccessRequest(db, "fol_a", "ana", later(REQUEST_EVERY_MS)), true);
44+ assert.equal(await claimAccessRequest(db, "fol_a", "ana", later(REQUEST_EVERY_MS + 60_000)), false);
45+ assert.equal(await claimAccessRequest(db, "fol_a", "ana", later(2 * REQUEST_EVERY_MS)), true);
46+});
47+
48+test("a refused press doesn't push the next allowed time back", async () => {
49+ const db = fakeD1();
50+ await claimAccessRequest(db, "fol_a", "ana", at);
51+ await claimAccessRequest(db, "fol_a", "ana", later(REQUEST_EVERY_MS - 1));
52+ assert.equal(await claimAccessRequest(db, "fol_a", "ana", later(REQUEST_EVERY_MS)), true);
53+});
+29−0
1+/**
2+ * Access requests' limit: a person asks for a folio at most once a day,
3+ * however many times they press the button. One row per person and folio
4+ * (`folio_access_requests`) holds when they last asked.
5+ */
6+
7+/** How long a person waits before asking for the same folio again. */
8+export const REQUEST_EVERY_MS = 24 * 60 * 60 * 1000;
9+/** The most people one request goes to: the owner and people with full access. */
10+export const REQUEST_RECIPIENTS = 20;
11+
12+/**
13+ * Records a request when the person may ask now, in one statement so two
14+ * presses at once send one request. True: send it. False: they asked for
15+ * this folio within the last day.
16+ */
17+export async function claimAccessRequest(db: D1Database, folioId: string, userId: string, at = new Date()): Promise<boolean> {
18+ const when = at.toISOString();
19+ const since = new Date(at.getTime() - REQUEST_EVERY_MS).toISOString();
20+ const row = await db
21+ .prepare(
22+ `INSERT INTO folio_access_requests (folio_id, user_id, requested_at) VALUES (?, ?, ?)
23+ ON CONFLICT (folio_id, user_id) DO UPDATE SET requested_at = excluded.requested_at WHERE folio_access_requests.requested_at <= ?
24+ RETURNING requested_at`,
25+ )
26+ .bind(folioId, userId, when, since)
27+ .first<{ requested_at: string }>();
28+ return row !== null;
29+}
+1−0
1616 folio_list: (s, a) => s.list(a),
1717 folio_sidebar: (s, a) => s.sidebar(a),
1818 folio: (s, a) => s.folio(a),
19+ folio_page: (s, a) => s.page(a),
1920 create_folio: (s, a) => s.create(a),
2021 update_folio: (s, a) => s.update(a),
2122 move_folio: (s, a) => s.move(a),
+56−3
5454 type FolioList,
5555 type FolioListQuery,
5656 type FolioMove,
57+ type FolioPage,
5758 type FolioPassage,
5859 type FolioProposal,
5960 type FolioRef,
111112 import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts";
112113 import { publishFolioEvent } from "./events.ts";
113114 import { MAX_DEPTH, cleanCover, cleanIcon, cleanNote, cleanSource, cleanTarget, cleanTitle, decodeCursor, depthOf, encodeCursor, listLimit, sharedTops, slugOf, subtreeHeight, treeNodes } from "./list.ts";
115+import { REQUEST_RECIPIENTS, claimAccessRequest } from "./requests.ts";
114116 import type { FolioRoom } from "./room.ts";
115117 import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts";
116118
525527 return ok(folio!);
526528 }
527529
530+ /** The folio, and what its page shows around it: the docs above it, what is under it, what links to it, open suggestions. */
531+ async page(a: Args & { folio_id: string }): Promise<Result<FolioPage>> {
532+ const found = await this.ctx(a.workspace, a.viewer);
533+ if (!found.ok) return found;
534+ const ctx = found.value;
535+ const opened = await this.open(ctx, a.folio_id, "view", { trashed: true, opening: true, text: true });
536+ if (!opened.ok) return opened;
537+ const { row, role } = opened.value;
538+ const at = now();
539+ this.defer(
540+ this.db
541+ .prepare("INSERT INTO folio_visits (folio_id, user_id, first_at, last_at) VALUES (?, ?, ?, ?) ON CONFLICT (folio_id, user_id) DO UPDATE SET last_at = excluded.last_at")
542+ .bind(row.id, ctx.viewer.id, at, at)
543+ .run(),
544+ );
545+ const above = row.path.split("/").filter((id) => id && id !== row.id);
546+ const [aboveRows, childRows, linkRows] = await Promise.all([
547+ foliosById(this.db, above),
548+ this.db.prepare(`SELECT ${FOLIO_COLUMNS} FROM folios WHERE parent_id = ? AND trashed_at IS NULL ORDER BY position LIMIT 200`).bind(row.id).all<FolioRow>(),
549+ this.db
550+ .prepare(`SELECT ${folioColumns("f")} FROM folio_links l JOIN folios f ON f.id = l.from_folio WHERE l.to_folio = ? AND f.workspace_id = ? AND f.trashed_at IS NULL LIMIT 200`)
551+ .bind(row.id, ctx.workspace.id)
552+ .all<FolioRow>(),
553+ ]);
554+ const parents = above.map((id) => aboveRows.get(id)).filter((r): r is FolioRow => !!r);
555+ const others = [...parents, ...childRows.results, ...linkRows.results.filter((r) => r.id !== row.id)];
556+ const { roles } = await this.roles(ctx, others);
557+ const readable = (list: FolioRow[]) => list.filter((r) => roles.get(r.id)).map((r) => this.ref(ctx.workspace.slug, r));
558+ const [folio] = await this.toFolios(ctx, [row], { roles: new Map([[row.id, role]]), found: opened.value.found });
559+ return ok({
560+ folio: folio!,
561+ text: row.text,
562+ breadcrumbs: readable(parents),
563+ children: readable(childRows.results),
564+ backlinks: readable(linkRows.results.filter((r) => r.id !== row.id)),
565+ suggestions: row.kind === "doc" ? await this.openSuggestions(ctx, row) : [],
566+ });
567+ }
568+
528569 // ── Making and changing ─────────────────────────────────────────────────
529570
530571 /** Where a new folio may go for this person: a parent doc they can edit, a space they can edit, or their Private. */
10991140 inherit: !!row.inherit,
11001141 inherited_from: inherited,
11011142 agent_mode: row.agent_mode,
1102− can_share: canShare(role),
1143+ can_share: canShare(role, this.editorsShare(ctx, row)),
11031144 public_link: "off",
11041145 };
11051146 }
11061147
1148+ /** Whether the folio's space lets people with edit access share what is in it. */
1149+ private editorsShare(ctx: Ctx, row: Pick<FolioRow, "space_id">): boolean {
1150+ return !!(row.space_id && ctx.spaceById.get(row.space_id)?.row.editors_can_share);
1151+ }
1152+
11071153 async access(a: Args & { folio_id: string }): Promise<Result<FolioAccessList>> {
11081154 const found = await this.ctx(a.workspace, a.viewer);
11091155 if (!found.ok) return found;
11361182 const opened = await this.open(ctx, a.folio_id, "view");
11371183 if (!opened.ok) return opened;
11381184 const { row, role } = opened.value;
1139− if (!canShare(role)) return fail("forbidden", "Only people with full access can share it.");
1185+ if (!canShare(role, this.editorsShare(ctx, row))) return fail("forbidden", "Only people with full access can share it.");
1186+ // Editors whose space lets them share give up to edit; full access stays with managers.
1187+ if (role !== "manage" && change.op === "grant" && change.role === "manage") return fail("forbidden", "Only people with full access can give full access.");
11401188 const principal = change.principal.startsWith("team:") ? `team:${change.principal.slice(5).toLowerCase()}` : change.principal;
11411189 if (principal === row.owner) return fail("invalid", "Its owner always has full access.");
1190+ if (role !== "manage") {
1191+ const held = await this.db.prepare("SELECT role FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).first<{ role: DocRole }>();
1192+ if (held?.role === "manage") return fail("forbidden", "Only people with full access can change someone else's full access.");
1193+ }
11421194 if (change.op === "revoke") {
11431195 await this.db.prepare("DELETE FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).run();
11441196 return ok(await this.afterShare(ctx, row));
12331285 const { roles } = await this.roles(ctx, [row]);
12341286 if (roles.get(row.id)) return ok(true);
12351287 if (!this.env.NOTIFY) return ok(true);
1288+ if (!(await claimAccessRequest(this.db, row.id, ctx.viewer.id))) return fail("conflict", "You already asked for access to this in the last day. Its owner has your request; you can ask again tomorrow.");
12361289 // The owner and anyone with full access through a grant hear of it.
12371290 const managers = (
12381291 await this.db.prepare("SELECT principal FROM folio_access WHERE folio_id = ? AND role = 'manage' AND principal LIKE 'user:%'").bind(row.id).all<{ principal: string }>()
12411294 const message = cleanNote(a.message);
12421295 const notify = notifyClient(this.env.NOTIFY);
12431296 await Promise.all(
1244− [...new Set([row.owner.slice(5), ...managers])].slice(0, 20).map((id) =>
1297+ [...new Set([row.owner.slice(5), ...managers])].slice(0, REQUEST_RECIPIENTS).map((id) =>
12451298 notify
12461299 .notify(
12471300 { user_id: id },
+4−1
147147 team: string | null;
148148 default_role: DocRole | null;
149149 agent_mode: DocAgentMode;
150+ editors_can_share: number;
150151 is_default: number;
151152 created_by: string;
152153 created_at: string;
482483 team: space.row.team,
483484 default_role: space.row.kind === "private" ? null : space.row.default_role,
484485 agent_mode: space.row.agent_mode,
486+ editors_can_share: !!space.row.editors_can_share,
485487 is_default: !!space.row.is_default,
486488 projects: space.projects,
487489 created_by: created,
758760 path: file.path,
759761 title: file.title,
760762 markdown: file.markdown,
761− href: `/${workspace.slug}/-/docs/repo/${repoPath}/${encoded}`,
763+ href: `/${workspace.slug}/-/artifacts/repo/${repoPath}/${encoded}`,
762764 code_href: `/${repoPath}/blob/${encodeURIComponent(match.repo.defaultBranch)}/${encoded}`,
763765 },
764766 });
942944 if (c.team !== undefined) set("team", c.team ? String(c.team).trim().toLowerCase() : null);
943945 if (c.default_role !== undefined && (c.kind ?? space.row.kind) !== "private") set("default_role", isRole(c.default_role) ? c.default_role : null);
944946 if (c.agent_mode !== undefined) set("agent_mode", c.agent_mode === "edit" ? "edit" : "suggest");
947+ if (c.editors_can_share !== undefined) set("editors_can_share", c.editors_can_share ? 1 : 0);
945948 if (c.archived !== undefined) {
946949 if (space.row.is_default && c.archived) return fail("invalid", "The General space can't be archived.");
947950 set("archived_at", c.archived ? now() : null);
+3−0
4444 team: string | null;
4545 default_role: DocRole | null;
4646 agent_mode: DocAgentMode;
47+ /** 1: people with edit access may share what is in it (migration 0005). */
48+ editors_can_share: number;
4749 is_default: number;
4850 created_by: string;
4951 created_at: string;
270272 team: space.row.team,
271273 default_role: space.row.kind === "private" ? null : space.row.default_role,
272274 agent_mode: space.row.agent_mode,
275+ editors_can_share: !!space.row.editors_can_share,
273276 is_default: !!space.row.is_default,
274277 projects: space.projects,
275278 created_by: created,