Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 1 | /** |
| 2 | * Who and where, for the docs service's folio code: the workspace by | |
| 3 | * slug, its people, agents and teams, how member keys show, and the | |
| 4 | * spaces with a person's role in each. Cached per request (one instance | |
| 5 | * per request). Docs' page code (src/index.ts, `Docs`) keeps its own copy | |
| 6 | * of these until Phase 7 of docs/ARTIFACTS_MODE.md removes it. | |
| 7 | */ | |
| 8 | import { | |
| 9 | fail, | |
| 10 | identityClient, | |
| 11 | newId, | |
| 12 | ok, | |
| 13 | parsePrincipalKey, | |
| 14 | principalKey, | |
| 15 | workspaceAgentsClient, | |
| 16 | type DocAgentMode, | |
| 17 | type DocRole, | |
| 18 | type DocSpace, | |
| 19 | type DocSpaceKind, | |
| 20 | type Member, | |
| 21 | type MemberProfile, | |
| 22 | type Principal, | |
| 23 | type Result, | |
| 24 | type ServiceBinding, | |
| 25 | type User, | |
| 26 | type Viewer, | |
| 27 | type Workspace, | |
| 28 | type WorkspaceAgent, | |
| 29 | } from "@g1t/contracts"; | |
| 30 | ||
| 31 | import { roleOf, type Person, type SpaceRules } from "./access.ts"; | |
| 32 | import { freeSlug } from "./slugs.ts"; | |
| 33 | ||
| 34 | export type WhoEnv = { DB: D1Database; IDENTITY: ServiceBinding; AGENTS: ServiceBinding }; | |
| 35 | ||
| 36 | export type SpaceRow = { | |
| 37 | id: string; | |
| 38 | workspace_id: string; | |
| 39 | slug: string; | |
| 40 | name: string; | |
| 41 | description: string | null; | |
| 42 | icon: string | null; | |
| 43 | kind: DocSpaceKind; | |
| 44 | team: string | null; | |
| 45 | default_role: DocRole | null; | |
| 46 | agent_mode: DocAgentMode; | |
| 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. | 47 | /** 1: people with edit access may share what is in it (migration 0005). */ |
| 48 | editors_can_share: number; | |
| The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 49 | is_default: number; |
| 50 | created_by: string; | |
| 51 | created_at: string; | |
| 52 | archived_at: string | null; | |
| 53 | }; | |
| 54 | ||
| 55 | /** A space, with who is in it and the viewer's role (null: they can't read it). */ | |
| 56 | export type Space = { row: SpaceRow; members: { principal: string; role: DocRole }[]; projects: string[]; role: DocRole | null }; | |
| 57 | ||
| 58 | export const now = () => new Date().toISOString(); | |
| 59 | ||
| 60 | export function rulesOf(space: Pick<Space, "row" | "members">): SpaceRules { | |
| 61 | return { kind: space.row.kind, team: space.row.team, default_role: space.row.default_role, members: space.members }; | |
| 62 | } | |
| 63 | ||
| 64 | export function isMember(viewer: Viewer, workspace: string): boolean { | |
| 65 | return !!viewer?.workspaces?.some((m) => m.slug === String(workspace ?? "").toLowerCase()); | |
| 66 | } | |
| 67 | ||
| 68 | export function userKey(viewer: Pick<User, "id">): string { | |
| 69 | return principalKey({ kind: "user", id: viewer.id }); | |
| 70 | } | |
| 71 | ||
| 72 | export class Who { | |
| 73 | private readonly workspaces = new Map<string, Promise<Workspace | null>>(); | |
| 74 | private readonly people = new Map<string, Promise<Map<string, Member>>>(); | |
| 75 | private readonly teams = new Map<string, Promise<Map<string, Set<string>>>>(); | |
| 76 | private readonly spaces = new Map<string, Promise<Omit<Space, "role">[]>>(); | |
| 77 | readonly usernames = new Map<string, string>(); | |
| 78 | private readonly agents = new Map<string, WorkspaceAgent | null>(); | |
| 79 | ||
| 80 | constructor(private readonly env: WhoEnv) {} | |
| 81 | ||
| 82 | workspace(slug: string): Promise<Workspace | null> { | |
| 83 | const key = String(slug ?? "").toLowerCase(); | |
| 84 | let found = this.workspaces.get(key); | |
| 85 | if (!found) { | |
| 86 | found = identityClient(this.env.IDENTITY).getWorkspace(key).catch(() => null); | |
| 87 | this.workspaces.set(key, found); | |
| 88 | } | |
| 89 | return found; | |
| 90 | } | |
| 91 | ||
| 92 | /** The workspace acting for itself: how this service asks identity about its members. */ | |
| 93 | actor(workspace: Workspace): User { | |
| 94 | return { id: workspace.id, username: workspace.slug, kind: "workspace", verified: true, workspaces: [{ slug: workspace.slug, role: "member" }] }; | |
| 95 | } | |
| 96 | ||
| 97 | /** The workspace's people by username (lowercased). */ | |
| 98 | members(workspace: Workspace): Promise<Map<string, Member>> { | |
| 99 | let found = this.people.get(workspace.id); | |
| 100 | if (!found) { | |
| 101 | found = identityClient(this.env.IDENTITY) | |
| 102 | .listMembers(workspace.slug, this.actor(workspace)) | |
| 103 | .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), m]) : [])) | |
| 104 | .catch(() => new Map<string, Member>()); | |
| 105 | this.people.set(workspace.id, found); | |
| 106 | } | |
| 107 | return found; | |
| 108 | } | |
| 109 | ||
| 110 | /** Each member's teams (slugs, lowercased), by username. */ | |
| 111 | teamsOf(workspace: Workspace): Promise<Map<string, Set<string>>> { | |
| 112 | let found = this.teams.get(workspace.id); | |
| 113 | if (!found) { | |
| 114 | found = identityClient(this.env.IDENTITY) | |
| 115 | .teamMemberships(this.actor(workspace), workspace.slug) | |
| 116 | .then((r) => new Map(r.ok ? r.value.map((m) => [m.username.toLowerCase(), new Set(m.teams.map((t) => t.slug.toLowerCase()))]) : [])) | |
| 117 | .catch(() => new Map<string, Set<string>>()); | |
| 118 | this.teams.set(workspace.id, found); | |
| 119 | } | |
| 120 | return found; | |
| 121 | } | |
| 122 | ||
| 123 | async nameUsers(ids: string[]): Promise<void> { | |
| 124 | const unnamed = [...new Set(ids)].filter((id) => !this.usernames.has(id)); | |
| 125 | if (!unnamed.length) return; | |
| 126 | const named = await identityClient(this.env.IDENTITY) | |
| 127 | .usernames(unnamed) | |
| 128 | .catch(() => ({}) as Record<string, string>); | |
| 129 | for (const [id, username] of Object.entries(named)) this.usernames.set(id, username); | |
| 130 | } | |
| 131 | ||
| 132 | async agentsById(ids: string[]): Promise<Map<string, WorkspaceAgent | null>> { | |
| 133 | const wanted = [...new Set(ids)].filter((id) => !this.agents.has(id)); | |
| 134 | if (wanted.length) { | |
| 135 | let found: WorkspaceAgent[] = []; | |
| 136 | try { | |
| 137 | found = await workspaceAgentsClient(this.env.AGENTS).byIds(wanted); | |
| 138 | } catch (error) { | |
| 139 | console.error("folios could not resolve agents", error); | |
| 140 | } | |
| 141 | for (const id of wanted) this.agents.set(id, found.find((a) => a.id === id) ?? null); | |
| 142 | } | |
| 143 | return new Map(ids.map((id) => [id, this.agents.get(id) ?? null])); | |
| 144 | } | |
| 145 | ||
| 146 | /** How member keys show. Anything that isn't a person or agent shows as g1t. */ | |
| 147 | async profiles(workspace: Workspace, keys: string[]): Promise<Map<string, MemberProfile>> { | |
| 148 | const principals = [...new Set(keys)].map((k) => parsePrincipalKey(k)).filter((p): p is Principal => !!p); | |
| 149 | const userIds = principals.filter((p) => p.kind === "user").map((p) => p.id); | |
| 150 | const agentIds = principals.filter((p) => p.kind === "agent").map((p) => p.id); | |
| 151 | const [, people, agents] = await Promise.all([this.nameUsers(userIds), userIds.length ? this.members(workspace) : new Map<string, Member>(), this.agentsById(agentIds)]); | |
| 152 | const out = new Map<string, MemberProfile>(); | |
| 153 | for (const p of principals) { | |
| 154 | if (p.kind === "user") { | |
| 155 | const username = this.usernames.get(p.id) ?? null; | |
| 156 | const person = username ? people.get(username.toLowerCase()) : undefined; | |
| 157 | out.set(principalKey(p), { ...p, name: username ?? "ghost", display_name: person?.name || username || "Former member", avatar: person?.avatar ?? null, role: null, title: null, avatar_seed: null }); | |
| 158 | } else { | |
| 159 | const agent = agents.get(p.id) ?? null; | |
| 160 | out.set(principalKey(p), { | |
| 161 | ...p, | |
| 162 | name: agent?.handle ?? p.id, | |
| 163 | display_name: agent?.display_name ?? "Former agent", | |
| 164 | avatar: agent?.avatar ?? null, | |
| 165 | role: agent?.role ?? null, | |
| 166 | title: agent?.title || null, | |
| 167 | avatar_seed: agent?.avatar_seed ?? null, | |
| 168 | }); | |
| 169 | } | |
| 170 | } | |
| 171 | for (const key of keys) { | |
| 172 | if (!out.has(key)) out.set(key, { kind: "user", id: key, name: "g1t", display_name: "g1t", avatar: null, role: null, title: null, avatar_seed: null }); | |
| 173 | } | |
| 174 | return out; | |
| 175 | } | |
| 176 | ||
| 177 | async viewerWorkspace(slug: string, viewer: Viewer): Promise<Result<Workspace>> { | |
| 178 | if (!viewer?.id) return fail("unauthenticated", "Sign in to use Artifacts."); | |
| 179 | if (!slug || !isMember(viewer, slug)) return fail("forbidden", "Only members of a workspace can use its Artifacts."); | |
| 180 | const workspace = await this.workspace(slug); | |
| 181 | return workspace ? ok(workspace) : fail("not_found", "No such workspace."); | |
| 182 | } | |
| 183 | ||
| 184 | viewerOwner(viewer: User, slug: string): boolean { | |
| 185 | return !!viewer.workspaces?.some((m) => m.slug === slug.toLowerCase() && m.role === "owner"); | |
| 186 | } | |
| 187 | ||
| 188 | /** A person as access sees them: their teams, and whether they own the workspace. */ | |
| 189 | async personOf(workspace: Workspace, user: Pick<User, "id" | "username">, owner: boolean): Promise<Person> { | |
| 190 | const teams = (await this.teamsOf(workspace)).get(String(user.username ?? "").toLowerCase()) ?? new Set<string>(); | |
| 191 | return { user_id: user.id, owner, teams }; | |
| 192 | } | |
| 193 | ||
| 194 | /** The viewer as access sees them. */ | |
| 195 | viewerPerson(workspace: Workspace, viewer: User): Promise<Person> { | |
| 196 | return this.personOf(workspace, viewer, this.viewerOwner(viewer, workspace.slug)); | |
| 197 | } | |
| 198 | ||
| 199 | /** People by user id as access sees them: members' teams and ownership; anyone else reads nothing. */ | |
| 200 | async peopleByIds(workspace: Workspace, ids: string[]): Promise<Person[]> { | |
| 201 | const unique = [...new Set(ids.map(String))].slice(0, 200); | |
| 202 | await this.nameUsers(unique); | |
| 203 | const [members, teams] = await Promise.all([this.members(workspace), this.teamsOf(workspace)]); | |
| 204 | return unique.map((id) => { | |
| 205 | const username = this.usernames.get(id)?.toLowerCase() ?? ""; | |
| 206 | const member = members.get(username); | |
| 207 | return { user_id: member ? id : `outside:${id}`, owner: member?.role === "owner", teams: member ? (teams.get(username) ?? new Set()) : new Set() }; | |
| 208 | }); | |
| 209 | } | |
| 210 | ||
| 211 | /** Every space in the workspace (archived too), with members and projects. */ | |
| 212 | allSpaces(workspace: Workspace): Promise<Omit<Space, "role">[]> { | |
| 213 | let found = this.spaces.get(workspace.id); | |
| 214 | if (!found) { | |
| 215 | found = (async () => { | |
| 216 | const db = this.env.DB; | |
| 217 | const [spaces, members, projects] = await Promise.all([ | |
| 218 | db.prepare("SELECT * FROM spaces WHERE workspace_id = ? ORDER BY is_default DESC, name COLLATE NOCASE").bind(workspace.id).all<SpaceRow>(), | |
| 219 | db | |
| 220 | .prepare("SELECT m.space_id, m.principal, m.role FROM space_members m JOIN spaces s ON s.id = m.space_id WHERE s.workspace_id = ?") | |
| 221 | .bind(workspace.id) | |
| 222 | .all<{ space_id: string; principal: string; role: DocRole }>(), | |
| 223 | db.prepare("SELECT p.space_id, p.repo FROM space_projects p JOIN spaces s ON s.id = p.space_id WHERE s.workspace_id = ?").bind(workspace.id).all<{ space_id: string; repo: string }>(), | |
| 224 | ]); | |
| 225 | return spaces.results.map((row) => ({ | |
| 226 | row, | |
| 227 | members: members.results.filter((m) => m.space_id === row.id).map((m) => ({ principal: m.principal, role: m.role })), | |
| 228 | projects: projects.results.filter((p) => p.space_id === row.id).map((p) => p.repo), | |
| 229 | })); | |
| 230 | })(); | |
| 231 | this.spaces.set(workspace.id, found); | |
| 232 | } | |
| 233 | return found; | |
| 234 | } | |
| 235 | ||
| 236 | /** Forget cached spaces after one changed. */ | |
| 237 | forgetSpaces(): void { | |
| 238 | this.spaces.clear(); | |
| 239 | } | |
| 240 | ||
| 241 | /** The spaces with `person`'s role in each (null: they can't read it). Archived spaces too. */ | |
| 242 | async spacesFor(workspace: Workspace, person: Person): Promise<Space[]> { | |
| 243 | const spaces = await this.allSpaces(workspace); | |
| 244 | return spaces.map((s) => ({ ...s, role: roleOf(rulesOf(s), person) })); | |
| 245 | } | |
| 246 | ||
| 247 | /** Makes the workspace's General space, once. */ | |
| 248 | async ensureDefault(workspace: Workspace, viewer: User): Promise<void> { | |
| 249 | const db = this.env.DB; | |
| 250 | const found = await db.prepare("SELECT id FROM spaces WHERE workspace_id = ? AND is_default = 1").bind(workspace.id).first<{ id: string }>(); | |
| 251 | if (found) return; | |
| 252 | const taken = new Set((await db.prepare("SELECT slug FROM spaces WHERE workspace_id = ?").bind(workspace.id).all<{ slug: string }>()).results.map((r) => r.slug)); | |
| 253 | await db | |
| 254 | .prepare( | |
| 255 | "INSERT OR IGNORE INTO spaces (id, workspace_id, slug, name, description, icon, kind, team, default_role, agent_mode, is_default, created_by, created_at) VALUES (?, ?, ?, 'General', 'Everything the whole workspace should know.', '📚', 'workspace', NULL, 'edit', 'suggest', 1, ?, ?)", | |
| 256 | ) | |
| 257 | .bind(newId("spc"), workspace.id, freeSlug("general", taken), userKey(viewer), now()) | |
| 258 | .run(); | |
| 259 | this.forgetSpaces(); | |
| 260 | } | |
| 261 | ||
| 262 | toSpace(space: Space, pageCount = 0): DocSpace { | |
| 263 | const created = parsePrincipalKey(space.row.created_by) ?? { kind: "user" as const, id: space.row.created_by }; | |
| 264 | return { | |
| 265 | id: space.row.id, | |
| 266 | workspace_id: space.row.workspace_id, | |
| 267 | slug: space.row.slug, | |
| 268 | name: space.row.name, | |
| 269 | description: space.row.description, | |
| 270 | icon: space.row.icon, | |
| 271 | kind: space.row.kind, | |
| 272 | team: space.row.team, | |
| 273 | default_role: space.row.kind === "private" ? null : space.row.default_role, | |
| 274 | agent_mode: space.row.agent_mode, | |
| 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. | 275 | editors_can_share: !!space.row.editors_can_share, |
| The docs service answers every artifacts call: docs can be made, listed, shared, moved, trashed, restored, searched, versioned and edited live in their own rooms, agents read, write and recall them only where their person and everyone in the conversation can, and folio events go out on the bus, while Docs' pages keep working as before. | 276 | is_default: !!space.row.is_default, |
| 277 | projects: space.projects, | |
| 278 | created_by: created, | |
| 279 | created_at: space.row.created_at, | |
| 280 | archived_at: space.row.archived_at, | |
| 281 | viewer_role: space.role ?? "view", | |
| 282 | page_count: pageCount, | |
| 283 | }; | |
| 284 | } | |
| 285 | } |