The Coline App SDK: apps, tools, file types and surfaces that run inside Coline workspaces, and public websites with Coline Sites. TypeScript, with an in-memory test workspace. MIT.
| ColineScheduleContextV2, |
| 6 | ColineToolResult, |
| 7 | } from "../define/types"; |
| 8 | import type { ColineUiNode } from "../tree/tree"; |
| 9 | import { validateChatCardTree } from "../tree/chat-card"; |
| 10 | import { |
| 11 | TestWorkspaceState, |
| 12 | createFakeCapabilities, |
| 13 | type TestJobRun, |
| 14 | type TestWorkspaceOptions, |
| 15 | } from "./fake-capabilities"; |
| 16 | import { eventPatternMatches } from "../manifest/triggers"; |
| 17 | import { toEndpointResponse, toJobRunResult } from "../server/kinds"; |
| 18 | import { parseEndpointJson } from "../server/runtime-helpers"; |
| 19 | import { guardTestCapabilities } from "./guarded-capabilities"; |
| 20 | import { siteToolEffect, type ToolEffect } from "../manifest/effect"; |
| 21 | import type { AppPermissionV2 } from "../manifest/permissions"; |
| 22 | import type { |
| 23 | ColineCapabilities, |
| 24 | ColineCollection, |
| 25 | ColineMemberSummary, |
| 26 | ColineVisitorRef, |
| 27 | } from "../capabilities/types"; |
| 28 | import { createFakeCollection } from "./fake-collection"; |
| 29 |
| 30 | /** |
| 31 | * `createTestWorkspace` (plan §14.2): a fake workspace + runtime for app |
| 32 | * tests. Invoke tools, render surfaces, and fire schedules exactly the |
| 33 | * way the platform does — input parsed through the tool's schema, cards |
| 34 | * validated against the chat subset, render contexts read-shaped — then |
| 35 | * assert on the inspectable state. |
| 36 | * |
| 37 | * ```ts |
| 38 | * const ws = createTestWorkspace(app); |
| 39 | * const result = await ws.invokeTool("crm.create_contact", { name: "Sarah" }); |
| 40 | * expect(ws.files.byType("crm.contact")).toHaveLength(1); |
| 41 | * ``` |
| 42 | */ |
| 43 |
| 44 | export interface InvokeToolResult { |
| 45 | output: unknown; |
| 46 | card: ColineUiNode | null; |
| 47 | } |
| 48 |
| 49 | export interface TestWorkspace { |
| 50 | state: TestWorkspaceState; |
| 51 | client(surface?: "home" | "editor" | "preview"): ColineCapabilities; |
| 52 | clientAs(userId: string, surface?: "home" | "editor" | "preview"): ColineCapabilities; |
| 53 | files: { |
| 54 | byType( |
| 55 | typeKey: string, |
| 56 | ): Array<{ fileId: string; name: string; document: Record<string, unknown> }>; |
| 57 | all(): Array<{ fileId: string; name: string; typeKey: string }>; |
| 58 | }; |
| 59 | invokeTool(name: string, input: unknown, userId?: string): Promise<InvokeToolResult>; |
| 60 | renderHome(): Promise<ColineUiNode>; |
| 61 | renderFileSurface( |
| 62 | surface: "editor" | "preview" | "inline", |
| 63 | file: { fileId: string; name: string; typeKey: string; version?: number }, |
| 64 | document?: Record<string, unknown>, |
| 65 | ): Promise<ColineUiNode>; |
| 66 | fireSchedule(scheduleId: string, at?: Date): Promise<void>; |
| 67 | /** |
| 68 | * Deliver an event to `onEvent` the way the platform does — only when |
| 69 | * the manifest subscribes to it. Returns false when unsubscribed. |
| 70 | */ |
| 71 | fireEvent( |
| 72 | type: string, |
| 73 | data?: Record<string, unknown>, |
| 74 | options?: { actorUserId?: string; sourceAppKey?: string }, |
| 75 | ): Promise<boolean>; |
| 76 | /** Send a request to a declared endpoint, as if verification passed. */ |
| 77 | request( |
| 78 | endpointId: string, |
| 79 | request?: { |
| 80 | method?: string; |
| 81 | path?: string; |
| 82 | query?: Record<string, string>; |
| 83 | headers?: Record<string, string>; |
| 84 | body?: string | Record<string, unknown>; |
| 85 | }, |
| 86 | ): Promise<{ status: number; headers: Record<string, string>; body: unknown }>; |
| 87 | /** |
| 88 | * Run queued background jobs until none are due (bounded by `maxRuns`). |
| 89 | * Failed attempts retry immediately in tests; returns the runs executed. |
| 90 | */ |
| 91 | runJobs(options?: { maxRuns?: number; now?: Date }): Promise<TestJobRun[]>; |
| 92 | /** |
| 93 | * Run a tool the site lists, as a visitor would: the tool's calls run as |
| 94 | * the app at most at the `write` effect, and records it creates belong to |
| 95 | * the visitor. |
| 96 | */ |
| 97 | invokeSiteTool(name: string, input: unknown, visitor?: TestVisitor): Promise<InvokeToolResult>; |
| 98 | /** |
| 99 | * What a site page sees calling collections directly: the collection's |
| 100 | * declared visitor rules, and nothing a page may not call. |
| 101 | */ |
| 102 | asVisitor(visitor?: TestVisitor): { collection<T>(name: string): ColineCollection<T> }; |
| 103 | } |
| 104 |
| 105 | export interface TestVisitor { |
| 106 | visitorId?: string; |
| 107 | signedIn?: boolean; |
| 108 | name?: string | null; |
| 109 | email?: string | null; |
| 110 | } |
| 111 |
| 112 | function visitorRef(visitor: TestVisitor = {}): ColineVisitorRef { |
| 113 | const signedIn = visitor.signedIn ?? false; |
| 114 | return { |
| 115 | kind: "visitor", |
| 116 | visitorId: visitor.visitorId ?? "vis_test", |
| 117 | signedIn, |
| 118 | name: signedIn ? (visitor.name ?? "Test Visitor") : null, |
| 119 | email: signedIn ? (visitor.email ?? "visitor@example.com") : null, |
| 120 | }; |
| 121 | } |
| 122 |
| 123 | export function createTestWorkspace( |
| 124 | app: ColineAppV2, |
| 125 | options: TestWorkspaceOptions = {}, |
| 126 | ): TestWorkspace { |
| 127 | const state = new TestWorkspaceState(); |
| 128 | const resolved = { |
| 129 | workspaceId: options.workspaceId ?? "ws_test", |
| 130 | workspaceSlug: options.workspaceSlug ?? "test-workspace", |
| 131 | workspaceName: options.workspaceName ?? "Test Workspace", |
| 132 | appKey: options.appKey ?? app.manifest.key, |
| 133 | installId: options.installId ?? "inst_test", |
| 134 | }; |
| 135 | const members: ColineMemberSummary[] = (options.members ?? [{ userId: "user_test" }]).map( |
| 136 | (member) => ({ |
| 137 | userId: member.userId, |
| 138 | email: member.email ?? `${member.userId}@example.com`, |
| 139 | handle: member.handle ?? member.userId, |
| 140 | firstName: member.firstName ?? "Test", |
| 141 | lastName: member.lastName ?? "User", |
| 142 | avatarUrl: member.avatarUrl ?? null, |
| 143 | }), |
| 144 | ); |
| 145 | const actorUserId = members[0]?.userId ?? "user_test"; |
| 146 | for (const [key, connection] of Object.entries(options.connections ?? {})) { |
| 147 | state.connections.set(key, { |
| 148 | accountLabel: connection.accountLabel ?? null, |
| 149 | scopes: connection.scopes ?? [], |
| 150 | connectedAt: new Date().toISOString(), |
| 151 | }); |
| 152 | } |
| 153 |
| 154 | function capabilities( |
| 155 | effect: ToolEffect, |
| 156 | interactive = false, |
| 157 | appPrincipal = false, |
| 158 | userId = actorUserId, |
| 159 | readAsUserId: string | null = null, |
| 160 | ): ReturnType<typeof guardTestCapabilities> { |
| 161 | if (!appPrincipal && !members.some((member) => member.userId === userId)) |
| 162 | throw Object.assign(new Error("Actor is not a member."), { code: "actor_not_member" }); |
| 163 | const raw = createFakeCapabilities(state, { |
| 164 | ...resolved, |
| 165 | members, |
| 166 | actor: appPrincipal ? { kind: "app" } : { kind: "user", userId }, |
| 167 | onNetFetch: options.onNetFetch, |
| 168 | onAiGenerate: options.onAiGenerate, |
| 169 | aiModels: options.aiModels, |
| 170 | onAiJob: options.onAiJob, |
| 171 | pickers: options.pickers ?? {}, |
| 172 | manifest: app.manifest, |
| 173 | destructiveAllowed: effect === "destructive", |
| 174 | effect, |
| 175 | }); |
| 176 | return guardTestCapabilities( |
| 177 | raw, |
| 178 | { |
| 179 | manifestPermissions: app.manifest.permissions, |
| 180 | grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, |
| 181 | effectCeiling: effect, |
| 182 | }, |
| 183 | { |
| 184 | interactive, |
| 185 | appPrincipal, |
| 186 | // Like production, an event's reads are evaluated as its actor |
| 187 | // while they are still a member. |
| 188 | ...(appPrincipal && readAsUserId && members.some((member) => member.userId === readAsUserId) |
| 189 | ? { readAs: capabilities("read", false, false, readAsUserId) } |
| 190 | : {}), |
| 191 | }, |
| 192 | ); |
| 193 | } |
| 194 | const coline = capabilities("read"); |
| 195 |
| 196 | const base = { |
| 197 | coline, |
| 198 | workspace: { |
| 199 | workspaceId: resolved.workspaceId, |
| 200 | workspaceSlug: resolved.workspaceSlug, |
| 201 | workspaceName: resolved.workspaceName, |
| 202 | }, |
| 203 | app: { appKey: resolved.appKey, installId: resolved.installId }, |
| 204 | }; |
| 205 |
| 206 | // Site calls pass the same permission gate as on Coline: declared and granted. |
| 207 | const assertSiteGrant = (permission: AppPermissionV2) => { |
| 208 | const granted = options.grantedPermissions ?? app.manifest.permissions; |
| 209 | if (!app.manifest.permissions.includes(permission) || !granted.includes(permission)) { |
| 210 | throw Object.assign(new Error(`Permission "${permission}" is not granted to this app.`), { |
| 211 | code: "permission_not_granted", |
| 212 | }); |
| 213 | } |
| 214 | }; |
| 215 |
| 216 | const workspace: TestWorkspace = { |
| 217 | state, |
| 218 | client: (surface = "home") => workspace.clientAs(actorUserId, surface), |
| 219 | clientAs: (userId, surface = "home") => { |
| 220 | const client = capabilities( |
| 221 | surface === "preview" ? "read" : "destructive", |
| 222 | true, |
| 223 | false, |
| 224 | userId, |
| 225 | ); |
| 226 | return { |
| 227 | ...client, |
| 228 | tools: { |
| 229 | invoke: async (name, input = {}) => { |
| 230 | const { createCapabilityGate } = await import("../capabilities/gate"); |
| 231 | createCapabilityGate({ |
| 232 | manifestPermissions: app.manifest.permissions, |
| 233 | grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, |
| 234 | effectCeiling: surface === "preview" ? "read" : "destructive", |
| 235 | }).assert("tools.invoke"); |
| 236 | const result = await workspace.invokeTool(name, input, userId); |
| 237 | return { output: result.output, ...(result.card ? { card: result.card } : {}) }; |
| 238 | }, |
| 239 | }, |
| 240 | }; |
| 241 | }, |
| 242 | files: { |
| 243 | byType: (typeKey) => |
| 244 | state |
| 245 | .filesByType(typeKey) |
| 246 | .map((file) => ({ fileId: file.fileId, name: file.name, document: file.document })), |
| 247 | all: () => |
| 248 | [...state.files.values()] |
| 249 | .filter((file) => !file.trashed) |
| 250 | .map((file) => ({ fileId: file.fileId, name: file.name, typeKey: file.typeKey })), |
| 251 | }, |
| 252 |
| 253 | async invokeTool(name, input, userId = actorUserId) { |
| 254 | const tool = app.runtime.tools.get(name); |
| 255 | if (!tool) { |
| 256 | throw new Error(`App "${app.manifest.key}" does not define tool "${name}".`); |
| 257 | } |
| 258 | const parsed = tool.input.safeParse(input); |
| 259 | if (!parsed.success) { |
| 260 | throw new Error( |
| 261 | `Tool input is invalid: ${parsed.error.issues |
| 262 | .map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`) |
| 263 | .join("; ")}`, |
| 264 | ); |
| 265 | } |
| 266 | const result: ColineToolResult = await tool.execute(parsed.data as never, { |
| 267 | ...base, |
| 268 | coline: capabilities(tool.effect, false, false, userId), |
| 269 | actor: { kind: "user", userId }, |
| 270 | }); |
| 271 | let card: ColineUiNode | null = null; |
| 272 | if (result.card) { |
| 273 | const validation = validateChatCardTree(result.card); |
| 274 | if (!validation.ok) { |
| 275 | throw new Error(`Tool card rejected: ${validation.reason}`); |
| 276 | } |
| 277 | card = result.card; |
| 278 | } |
| 279 | return { output: result.output, card }; |
| 280 | }, |
| 281 |
| 282 | async renderHome() { |
| 283 | if (!app.runtime.renderHome) { |
| 284 | throw new Error(`App "${app.manifest.key}" has no tree home handler.`); |
| 285 | } |
| 286 | const context: ColineHomeRenderContextV2 = { |
| 287 | ...base, |
| 288 | actor: { kind: "user", userId: actorUserId }, |
| 289 | }; |
| 290 | return app.runtime.renderHome(context); |
| 291 | }, |
| 292 |
| 293 | async renderFileSurface(surface, file, document = {}) { |
| 294 | const runtime = app.runtime.fileTypes.get(file.typeKey); |
| 295 | const handler = |
| 296 | surface === "editor" |
| 297 | ? runtime?.renderEditor |
| 298 | : surface === "preview" |
| 299 | ? runtime?.renderPreview |
| 300 | : runtime?.renderInline; |
| 301 | if (!handler) { |
| 302 | throw new Error(`File type "${file.typeKey}" has no ${surface} handler.`); |
| 303 | } |
| 304 | const context: ColineFileRenderContextV2 = { |
| 305 | ...base, |
| 306 | actor: { kind: "user", userId: actorUserId }, |
| 307 | file: { version: 1, ...file }, |
| 308 | document, |
| 309 | }; |
| 310 | const tree = await handler(context); |
| 311 | if (surface === "inline") { |
| 312 | const validation = validateChatCardTree(tree); |
| 313 | if (!validation.ok) { |
| 314 | throw new Error(`Inline surface rejected: ${validation.reason}`); |
| 315 | } |
| 316 | } |
| 317 | return tree; |
| 318 | }, |
| 319 |
| 320 | async fireSchedule(scheduleId, at = new Date()) { |
| 321 | if (!app.runtime.onSchedule) { |
| 322 | throw new Error(`App "${app.manifest.key}" has no schedule handler.`); |
| 323 | } |
| 324 | const declared = app.manifest.triggers.schedules.find( |
| 325 | (schedule) => schedule.id === scheduleId, |
| 326 | ); |
| 327 | if (!declared) { |
| 328 | throw new Error(`Schedule "${scheduleId}" is not declared in the manifest.`); |
| 329 | } |
| 330 | const context: ColineScheduleContextV2 = { |
| 331 | coline: capabilities("write", false, true), |
| 332 | workspace: base.workspace, |
| 333 | app: base.app, |
| 334 | scheduleId, |
| 335 | firedAt: at.toISOString(), |
| 336 | }; |
| 337 | await app.runtime.onSchedule(context); |
| 338 | }, |
| 339 |
| 340 | async fireEvent(type, data = {}, eventOptions = {}) { |
| 341 | const subscribed = app.manifest.triggers.webhooks.some((subscription) => |
| 342 | eventPatternMatches(subscription.event, type), |
| 343 | ); |
| 344 | if (!subscribed) return false; |
| 345 | if (!app.runtime.onEvent) { |
| 346 | throw new Error(`App "${app.manifest.key}" has no onEvent handler.`); |
| 347 | } |
| 348 | eventCounter += 1; |
| 349 | await app.runtime.onEvent({ |
| 350 | coline: capabilities("write", false, true, actorUserId, eventOptions.actorUserId ?? null), |
| 351 | workspace: base.workspace, |
| 352 | app: base.app, |
| 353 | attempt: 1, |
| 354 | event: { |
| 355 | id: `evt_test_${eventCounter}`, |
| 356 | type, |
| 357 | occurredAt: new Date().toISOString(), |
| 358 | actor: eventOptions.actorUserId ? { kind: "user", userId: eventOptions.actorUserId } : null, |
| 359 | source: eventOptions.sourceAppKey |
| 360 | ? { kind: "app", appKey: eventOptions.sourceAppKey } |
| 361 | : { kind: "platform" }, |
| 362 | data: structuredClone(data), |
| 363 | }, |
| 364 | }); |
| 365 | return true; |
| 366 | }, |
| 367 |
| 368 | async request(endpointId, request = {}) { |
| 369 | const declared = app.manifest.endpoints.find((endpoint) => endpoint.id === endpointId); |
| 370 | const handler = app.runtime.endpoints.get(endpointId); |
| 371 | if (!declared || !handler) { |
| 372 | throw new Error(`App "${app.manifest.key}" does not define endpoint "${endpointId}".`); |
| 373 | } |
| 374 | const method = (request.method ?? declared.methods[0] ?? "POST").toUpperCase(); |
| 375 | if (!declared.methods.includes(method as never)) { |
| 376 | return { status: 405, headers: {}, body: { error: "method_not_allowed" } }; |
| 377 | } |
| 378 | const body = |
| 379 | typeof request.body === "string" |
| 380 | ? request.body |
| 381 | : request.body === undefined |
| 382 | ? "" |
| 383 | : JSON.stringify(request.body); |
| 384 | const response = toEndpointResponse( |
| 385 | await handler({ |
| 386 | coline: capabilities("write", false, true), |
| 387 | workspace: base.workspace, |
| 388 | app: base.app, |
| 389 | request: { |
| 390 | endpointId, |
| 391 | method, |
| 392 | path: request.path ?? "/", |
| 393 | query: request.query ?? {}, |
| 394 | headers: Object.fromEntries( |
| 395 | Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]), |
| 396 | ), |
| 397 | body, |
| 398 | bodyBase64: null, |
| 399 | receivedAt: new Date().toISOString(), |
| 400 | verifiedBy: declared.verify.kind, |
| 401 | }, |
| 402 | json: <T>() => parseEndpointJson<T>(body), |
| 403 | }), |
| 404 | ); |
| 405 | return { status: response.status, headers: response.headers, body: response.body ?? null }; |
| 406 | }, |
| 407 |
| 408 | async runJobs(runOptions = {}) { |
| 409 | const executed: TestJobRun[] = []; |
| 410 | const maxRuns = runOptions.maxRuns ?? 100; |
| 411 | while (executed.length < maxRuns) { |
| 412 | const nowMs = (runOptions.now ?? new Date()).getTime(); |
| 413 | const run = [...state.jobs.values()].find( |
| 414 | (candidate) => candidate.status === "queued" && Date.parse(candidate.runAt) <= nowMs, |
| 415 | ); |
| 416 | if (!run) break; |
| 417 | const definition = app.runtime.jobs.get(run.name); |
| 418 | const declared = app.manifest.jobs.find((job) => job.name === run.name); |
| 419 | if (!definition || !declared) { |
| 420 | run.status = "dead"; |
| 421 | run.error = { code: "unknown_job", message: `Job "${run.name}" is not defined.` }; |
| 422 | executed.push(run); |
| 423 | continue; |
| 424 | } |
| 425 | run.status = "running"; |
| 426 | run.attempt += 1; |
| 427 | run.startedAt = new Date().toISOString(); |
| 428 | const appPrincipal = run.actor.kind === "app"; |
| 429 | const coline = capabilities( |
| 430 | declared.effect, |
| 431 | false, |
| 432 | appPrincipal, |
| 433 | run.actor.kind === "user" ? run.actor.userId : actorUserId, |
| 434 | ); |
| 435 | try { |
| 436 | let payload: unknown = run.payload; |
| 437 | if (definition.payload) payload = definition.payload.parse(payload); |
| 438 | const outcome = toJobRunResult( |
| 439 | await definition.run(payload as never, { |
| 440 | coline, |
| 441 | workspace: base.workspace, |
| 442 | app: base.app, |
| 443 | actor: run.actor, |
| 444 | job: { |
| 445 | id: run.id, |
| 446 | name: run.name, |
| 447 | attempt: run.attempt, |
| 448 | maxAttempts: run.maxAttempts, |
| 449 | enqueuedAt: run.createdAt, |
| 450 | checkpoint: run.checkpoint, |
| 451 | saveCheckpoint: async (value) => { |
| 452 | run.checkpoint = structuredClone(value); |
| 453 | }, |
| 454 | progress: async (value, message) => { |
| 455 | run.progress = { value: Math.max(0, Math.min(1, value)), message: message ?? null }; |
| 456 | }, |
| 457 | }, |
| 458 | }), |
| 459 | ); |
| 460 | if (outcome.kind === "continue") { |
| 461 | run.payload = outcome.payload; |
| 462 | run.status = "queued"; |
| 463 | run.attempt = 0; |
| 464 | run.runAt = new Date(nowMs + (outcome.delaySeconds ?? 0) * 1000).toISOString(); |
| 465 | } else { |
| 466 | run.status = "succeeded"; |
| 467 | run.result = outcome.result ?? null; |
| 468 | run.finishedAt = new Date().toISOString(); |
| 469 | } |
| 470 | } catch (error) { |
| 471 | run.error = { |
| 472 | code: |
| 473 | error && typeof error === "object" && "code" in error ? String(error.code) : "job_failed", |
| 474 | message: error instanceof Error ? error.message : String(error), |
| 475 | }; |
| 476 | run.status = run.attempt >= run.maxAttempts ? "dead" : "queued"; |
| 477 | if (run.status === "dead") run.finishedAt = new Date().toISOString(); |
| 478 | } |
| 479 | executed.push(structuredClone(run)); |
| 480 | } |
| 481 | return executed; |
| 482 | }, |
| 483 |
| 484 | async invokeSiteTool(name, input, visitor) { |
| 485 | if (!app.manifest.surfaces.site?.tools.includes(name)) { |
| 486 | throw Object.assign(new Error(`Tool "${name}" is not listed in surfaces.site.tools.`), { |
| 487 | code: "site_tool_not_allowed", |
| 488 | }); |
| 489 | } |
| 490 | const tool = app.runtime.tools.get(name); |
| 491 | if (!tool) throw new Error(`App "${app.manifest.key}" does not define tool "${name}".`); |
| 492 | // Like Coline: running a tool from a site needs ai.tools granted. |
| 493 | assertSiteGrant("ai.tools"); |
| 494 | const parsed = tool.input.parse(input); |
| 495 | const actor = visitorRef(visitor); |
| 496 | const effect = siteToolEffect(tool.effect); |
| 497 | const raw = createFakeCapabilities(state, { |
| 498 | ...resolved, |
| 499 | members, |
| 500 | actor: { kind: "app" }, |
| 501 | createdBy: actor, |
| 502 | onNetFetch: options.onNetFetch, |
| 503 | onAiGenerate: options.onAiGenerate, |
| 504 | aiModels: options.aiModels, |
| 505 | onAiJob: options.onAiJob, |
| 506 | pickers: options.pickers ?? {}, |
| 507 | manifest: app.manifest, |
| 508 | destructiveAllowed: false, |
| 509 | effect, |
| 510 | }); |
| 511 | const result = await tool.execute(parsed as never, { |
| 512 | ...base, |
| 513 | coline: guardTestCapabilities( |
| 514 | raw, |
| 515 | { |
| 516 | manifestPermissions: app.manifest.permissions, |
| 517 | grantedPermissions: options.grantedPermissions ?? app.manifest.permissions, |
| 518 | effectCeiling: effect, |
| 519 | }, |
| 520 | { interactive: false, appPrincipal: true }, |
| 521 | ), |
| 522 | actor, |
| 523 | }); |
| 524 | if (result.card) { |
| 525 | const validation = validateChatCardTree(result.card); |
| 526 | if (!validation.ok) throw new Error(`Tool card rejected: ${validation.reason}`); |
| 527 | } |
| 528 | return { output: result.output, card: result.card ?? null }; |
| 529 | }, |
| 530 |
| 531 | asVisitor(visitor) { |
| 532 | if (!app.manifest.surfaces.site) { |
| 533 | throw new Error(`App "${app.manifest.key}" has no site surface.`); |
| 534 | } |
| 535 | const actor = visitorRef(visitor); |
| 536 | return { |
| 537 | collection: <T>(name: string) => { |
| 538 | assertSiteGrant("storage.app"); |
| 539 | const access = app.manifest.collections[name]?.access; |
| 540 | return createFakeCollection<T>(state, name, actor, resolved.installId, { |
| 541 | destructiveAllowed: true, |
| 542 | ...(access ? { rules: access } : {}), |
| 543 | }); |
| 544 | }, |
| 545 | }; |
| 546 | }, |
| 547 | }; |
| 548 | let eventCounter = 0; |
| 549 | return workspace; |
| 550 | } |
| 551 |