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.
10 files+185−50/10 viewed
| 498 | 498 | ||
| 499 | 499 | /// Every method the docs service answers for folios, as `FOLIO_RPC_METHODS` | |
| 500 | 500 | /// in folios.ts. | |
| 501 | − | pub const FOLIO_RPC_METHODS: [&str; 46] = [ | |
| 501 | + | pub const FOLIO_RPC_METHODS: [&str; 47] = [ | |
| 502 | 502 | "folio_list", | |
| 503 | 503 | "folio_sidebar", | |
| 504 | 504 | "folio", | |
| 505 | + | "folio_page", | |
| 505 | 506 | "create_folio", | |
| 506 | 507 | "update_folio", | |
| 507 | 508 | "move_folio", |
| 83 | 83 | /** What every workspace member (workspace) or team member (team) gets; null for a private space. */ | |
| 84 | 84 | default_role: DocRole | null; | |
| 85 | 85 | 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; | |
| 86 | 88 | /** The workspace's General space, made the first time Docs is opened. Can't be archived. */ | |
| 87 | 89 | is_default: boolean; | |
| 88 | 90 | /** Projects (repositories, `owner/name`) the space is about: Docs filters by them. */ | |
| ⋯ | |||
| 454 | 456 | default_role?: DocRole | null; | |
| 455 | 457 | agent_mode?: DocAgentMode | null; | |
| 456 | 458 | projects?: string[] | null; | |
| 459 | + | editors_can_share?: boolean | null; | |
| 457 | 460 | }; | |
| 458 | 461 | ||
| 459 | 462 | export type DocSpaceChange = Partial<Omit<NewDocSpace, "kind">> & { kind?: DocSpaceKind; archived?: boolean }; | |
| 266 | 266 | ||
| 267 | 267 | export type FolioList = { items: Folio[]; next_cursor: string | null }; | |
| 268 | 268 | ||
| 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 | + | ||
| 269 | 284 | export type FoliosSidebarSpace = DocSpace & { joined: boolean; tree: FolioTreeNode[] }; | |
| 270 | 285 | ||
| 271 | 286 | export type FoliosSidebar = { | |
| ⋯ | |||
| 599 | 614 | "folio_list", | |
| 600 | 615 | "folio_sidebar", | |
| 601 | 616 | "folio", | |
| 617 | + | "folio_page", | |
| 602 | 618 | // Changing folios. | |
| 603 | 619 | "create_folio", | |
| 604 | 620 | "update_folio", | |
| ⋯ | |||
| 659 | 675 | sidebar(workspace: string, viewer: User): Promise<Result<FoliosSidebar>>; | |
| 660 | 676 | /** 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. */ | |
| 661 | 677 | 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>>; | |
| 662 | 680 | create(workspace: string, viewer: User, input: NewFolio): Promise<Result<Folio>>; | |
| 663 | 681 | update(workspace: string, viewer: User, folioId: string, change: FolioChange): Promise<Result<Folio>>; | |
| 664 | 682 | /** Edit role where it is and where it goes. Refuses moving under itself, under a non-doc, or deeper than `FOLIO_MAX_DEPTH`. */ | |
| ⋯ | |||
| 750 | 768 | list: (workspace, viewer, query) => call("folio_list", { workspace, viewer, query }), | |
| 751 | 769 | sidebar: (workspace, viewer) => call("folio_sidebar", { workspace, viewer }), | |
| 752 | 770 | folio: (workspace, viewer, folioId) => call("folio", { workspace, viewer, folio_id: folioId }), | |
| 771 | + | page: (workspace, viewer, folioId) => call("folio_page", { workspace, viewer, folio_id: folioId }), | |
| 753 | 772 | create: (workspace, viewer, input) => call("create_folio", { workspace, viewer, input }), | |
| 754 | 773 | update: (workspace, viewer, folioId, change) => call("update_folio", { workspace, viewer, folio_id: folioId, change }), | |
| 755 | 774 | move: (workspace, viewer, folioId, move) => call("move_folio", { workspace, viewer, folio_id: folioId, move }), | |
| 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 | + | ); |
| 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 | + | }); |
| 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 | + | } |
| 16 | 16 | folio_list: (s, a) => s.list(a), | |
| 17 | 17 | folio_sidebar: (s, a) => s.sidebar(a), | |
| 18 | 18 | folio: (s, a) => s.folio(a), | |
| 19 | + | folio_page: (s, a) => s.page(a), | |
| 19 | 20 | create_folio: (s, a) => s.create(a), | |
| 20 | 21 | update_folio: (s, a) => s.update(a), | |
| 21 | 22 | move_folio: (s, a) => s.move(a), |
| 54 | 54 | type FolioList, | |
| 55 | 55 | type FolioListQuery, | |
| 56 | 56 | type FolioMove, | |
| 57 | + | type FolioPage, | |
| 57 | 58 | type FolioPassage, | |
| 58 | 59 | type FolioProposal, | |
| 59 | 60 | type FolioRef, | |
| ⋯ | |||
| 111 | 112 | import { agentMayFind, agentReach, audienceRule, type AgentReach, type AudienceRule } from "./agents.ts"; | |
| 112 | 113 | import { publishFolioEvent } from "./events.ts"; | |
| 113 | 114 | 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"; | |
| 114 | 116 | import type { FolioRoom } from "./room.ts"; | |
| 115 | 117 | import { builtinFolioTemplate, builtinFolioTemplates } from "./templates.ts"; | |
| 116 | 118 | ||
| ⋯ | |||
| 525 | 527 | return ok(folio!); | |
| 526 | 528 | } | |
| 527 | 529 | ||
| 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 | + | ||
| 528 | 569 | // ── Making and changing ───────────────────────────────────────────────── | |
| 529 | 570 | ||
| 530 | 571 | /** Where a new folio may go for this person: a parent doc they can edit, a space they can edit, or their Private. */ | |
| ⋯ | |||
| 1099 | 1140 | inherit: !!row.inherit, | |
| 1100 | 1141 | inherited_from: inherited, | |
| 1101 | 1142 | agent_mode: row.agent_mode, | |
| 1102 | − | can_share: canShare(role), | |
| 1143 | + | can_share: canShare(role, this.editorsShare(ctx, row)), | |
| 1103 | 1144 | public_link: "off", | |
| 1104 | 1145 | }; | |
| 1105 | 1146 | } | |
| 1106 | 1147 | ||
| 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 | + | ||
| 1107 | 1153 | async access(a: Args & { folio_id: string }): Promise<Result<FolioAccessList>> { | |
| 1108 | 1154 | const found = await this.ctx(a.workspace, a.viewer); | |
| 1109 | 1155 | if (!found.ok) return found; | |
| ⋯ | |||
| 1136 | 1182 | const opened = await this.open(ctx, a.folio_id, "view"); | |
| 1137 | 1183 | if (!opened.ok) return opened; | |
| 1138 | 1184 | 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."); | |
| 1140 | 1188 | const principal = change.principal.startsWith("team:") ? `team:${change.principal.slice(5).toLowerCase()}` : change.principal; | |
| 1141 | 1189 | 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 | + | } | |
| 1142 | 1194 | if (change.op === "revoke") { | |
| 1143 | 1195 | await this.db.prepare("DELETE FROM folio_grants WHERE folio_id = ? AND principal = ?").bind(row.id, principal).run(); | |
| 1144 | 1196 | return ok(await this.afterShare(ctx, row)); | |
| ⋯ | |||
| 1233 | 1285 | const { roles } = await this.roles(ctx, [row]); | |
| 1234 | 1286 | if (roles.get(row.id)) return ok(true); | |
| 1235 | 1287 | 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."); | |
| 1236 | 1289 | // The owner and anyone with full access through a grant hear of it. | |
| 1237 | 1290 | const managers = ( | |
| 1238 | 1291 | 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 }>() | |
| ⋯ | |||
| 1241 | 1294 | const message = cleanNote(a.message); | |
| 1242 | 1295 | const notify = notifyClient(this.env.NOTIFY); | |
| 1243 | 1296 | 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) => | |
| 1245 | 1298 | notify | |
| 1246 | 1299 | .notify( | |
| 1247 | 1300 | { user_id: id }, | |
| 147 | 147 | team: string | null; | |
| 148 | 148 | default_role: DocRole | null; | |
| 149 | 149 | agent_mode: DocAgentMode; | |
| 150 | + | editors_can_share: number; | |
| 150 | 151 | is_default: number; | |
| 151 | 152 | created_by: string; | |
| 152 | 153 | created_at: string; | |
| ⋯ | |||
| 482 | 483 | team: space.row.team, | |
| 483 | 484 | default_role: space.row.kind === "private" ? null : space.row.default_role, | |
| 484 | 485 | agent_mode: space.row.agent_mode, | |
| 486 | + | editors_can_share: !!space.row.editors_can_share, | |
| 485 | 487 | is_default: !!space.row.is_default, | |
| 486 | 488 | projects: space.projects, | |
| 487 | 489 | created_by: created, | |
| ⋯ | |||
| 758 | 760 | path: file.path, | |
| 759 | 761 | title: file.title, | |
| 760 | 762 | markdown: file.markdown, | |
| 761 | − | href: `/${workspace.slug}/-/docs/repo/${repoPath}/${encoded}`, | |
| 763 | + | href: `/${workspace.slug}/-/artifacts/repo/${repoPath}/${encoded}`, | |
| 762 | 764 | code_href: `/${repoPath}/blob/${encodeURIComponent(match.repo.defaultBranch)}/${encoded}`, | |
| 763 | 765 | }, | |
| 764 | 766 | }); | |
| ⋯ | |||
| 942 | 944 | if (c.team !== undefined) set("team", c.team ? String(c.team).trim().toLowerCase() : null); | |
| 943 | 945 | if (c.default_role !== undefined && (c.kind ?? space.row.kind) !== "private") set("default_role", isRole(c.default_role) ? c.default_role : null); | |
| 944 | 946 | 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); | |
| 945 | 948 | if (c.archived !== undefined) { | |
| 946 | 949 | if (space.row.is_default && c.archived) return fail("invalid", "The General space can't be archived."); | |
| 947 | 950 | set("archived_at", c.archived ? now() : null); | |
| 44 | 44 | team: string | null; | |
| 45 | 45 | default_role: DocRole | null; | |
| 46 | 46 | agent_mode: DocAgentMode; | |
| 47 | + | /** 1: people with edit access may share what is in it (migration 0005). */ | |
| 48 | + | editors_can_share: number; | |
| 47 | 49 | is_default: number; | |
| 48 | 50 | created_by: string; | |
| 49 | 51 | created_at: string; | |
| ⋯ | |||
| 270 | 272 | team: space.row.team, | |
| 271 | 273 | default_role: space.row.kind === "private" ? null : space.row.default_role, | |
| 272 | 274 | agent_mode: space.row.agent_mode, | |
| 275 | + | editors_can_share: !!space.row.editors_can_share, | |
| 273 | 276 | is_default: !!space.row.is_default, | |
| 274 | 277 | projects: space.projects, | |
| 275 | 278 | created_by: created, | |