@fungi.computer/oyster 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +133 -78
  2. package/dist/agent-archive-projection.d.ts +8 -0
  3. package/dist/agent-archive-projection.js +49 -0
  4. package/dist/agent-schedules.d.ts +61 -0
  5. package/dist/agent-schedules.js +19 -0
  6. package/dist/agents.d.ts +35 -14
  7. package/dist/agents.js +93 -144
  8. package/dist/app-builds.d.ts +7 -2
  9. package/dist/app-builds.js +19 -2
  10. package/dist/approval-capability.d.ts +1 -1
  11. package/dist/approval-capability.js +11 -16
  12. package/dist/approval-home.d.ts +2 -1
  13. package/dist/approval-home.js +5 -3
  14. package/dist/apps/catalog.js +1 -1
  15. package/dist/apps/gate.d.ts +4 -7
  16. package/dist/apps/gate.js +14 -29
  17. package/dist/apps/index.d.ts +1 -1
  18. package/dist/apps/installation.d.ts +4 -42
  19. package/dist/apps/installation.js +7 -75
  20. package/dist/apps/operations.js +0 -10
  21. package/dist/apps/permissions.d.ts +18 -8
  22. package/dist/apps/permissions.js +7 -1
  23. package/dist/auth.d.ts +1 -24
  24. package/dist/auth.js +9 -39
  25. package/dist/authorization.d.ts +307 -0
  26. package/dist/authorization.js +542 -0
  27. package/dist/boot-host-script.js +1 -1
  28. package/dist/build/build-jobs.d.ts +2 -2
  29. package/dist/build/candidate-builder.d.ts +2 -0
  30. package/dist/build/candidate-builder.js +5 -1
  31. package/dist/build/candidate-cli.js +3 -0
  32. package/dist/build/candidate-cli.mjs +70 -22
  33. package/dist/build/job-log.d.ts +23 -0
  34. package/dist/build/job-log.js +88 -0
  35. package/dist/build/local-build.d.ts +9 -1
  36. package/dist/build/local-build.js +11 -9
  37. package/dist/cloudflare.d.ts +4 -1
  38. package/dist/cloudflare.js +1 -1
  39. package/dist/files.js +9 -8
  40. package/dist/git-admission.d.ts +3 -3
  41. package/dist/git-admission.js +4 -6
  42. package/dist/git-door.d.ts +8 -7
  43. package/dist/git-door.js +18 -17
  44. package/dist/git.d.ts +1 -1
  45. package/dist/host.d.ts +33 -40
  46. package/dist/host.js +156 -275
  47. package/dist/installation-execution.js +14 -5
  48. package/dist/installation-operations.d.ts +4 -180
  49. package/dist/installation-operations.js +1 -5
  50. package/dist/installation-store-generation.d.ts +4 -1
  51. package/dist/installation-store-generation.js +59 -6
  52. package/dist/internal/delivery.js +0 -1
  53. package/dist/internal/host-gate.d.ts +3 -2
  54. package/dist/internal/host-gate.js +5 -18
  55. package/dist/internal/invocation-timing.d.ts +16 -0
  56. package/dist/internal/invocation-timing.js +23 -0
  57. package/dist/internal/registry-installations.js +1 -0
  58. package/dist/invocation.d.ts +15 -14
  59. package/dist/invocation.js +58 -26
  60. package/dist/keys.d.ts +2 -19
  61. package/dist/keys.js +1 -37
  62. package/dist/mcp.d.ts +15 -27
  63. package/dist/mcp.js +105 -155
  64. package/dist/page/agent-browser.d.ts +15 -0
  65. package/dist/page/agent-browser.js +162 -0
  66. package/dist/page/app-permissions-port.d.ts +3 -0
  67. package/dist/page/app-permissions-port.js +43 -0
  68. package/dist/page/app-permissions.d.ts +11 -0
  69. package/dist/page/app-permissions.js +296 -0
  70. package/dist/page/isolated-page-theme.d.ts +2 -0
  71. package/dist/page/isolated-page-theme.js +11 -0
  72. package/dist/page/mount.d.ts +2 -13
  73. package/dist/page/mount.js +7 -93
  74. package/dist/page-script.d.ts +0 -3
  75. package/dist/page-script.js +3 -4
  76. package/dist/standalone/agent-archive-owner.d.ts +42 -0
  77. package/dist/standalone/agent-archive-owner.js +116 -0
  78. package/dist/standalone/agent-archive-schedules.d.ts +6 -0
  79. package/dist/standalone/agent-archive-schedules.js +76 -0
  80. package/dist/standalone/agent-archive.d.ts +31 -0
  81. package/dist/standalone/agent-archive.js +67 -0
  82. package/dist/standalone/agent-computers.d.ts +7 -6
  83. package/dist/standalone/agent-computers.js +4 -7
  84. package/dist/standalone/agent-mail.d.ts +2 -2
  85. package/dist/standalone/agents.d.ts +186 -20
  86. package/dist/standalone/agents.js +251 -30
  87. package/dist/standalone/app-builds-binding.js +1 -1
  88. package/dist/standalone/app-set.js +2 -54
  89. package/dist/standalone/app-source.d.ts +3 -4
  90. package/dist/standalone/app-source.js +12 -9
  91. package/dist/standalone/builds.d.ts +2 -14
  92. package/dist/standalone/builds.js +15 -13
  93. package/dist/standalone/computer-fleet.d.ts +4 -4
  94. package/dist/standalone/computer-fleet.js +13 -42
  95. package/dist/standalone/computer.d.ts +4 -3
  96. package/dist/standalone/computer.js +52 -53
  97. package/dist/standalone/dashboard-approval.d.ts +10 -0
  98. package/dist/standalone/dashboard-approval.js +66 -0
  99. package/dist/standalone/machine-link.d.ts +4 -221
  100. package/dist/standalone/machine-link.js +21 -15
  101. package/dist/standalone/media.d.ts +1 -0
  102. package/dist/standalone/media.js +20 -14
  103. package/dist/standalone/owner-routes.d.ts +16 -0
  104. package/dist/standalone/owner-routes.js +21 -0
  105. package/dist/standalone/permission-resources.d.ts +5 -0
  106. package/dist/standalone/permission-resources.js +31 -0
  107. package/dist/standalone/registry.d.ts +7 -0
  108. package/dist/standalone/registry.js +3 -0
  109. package/dist/standalone/tenancy.d.ts +0 -3
  110. package/dist/standalone/tenancy.js +2 -5
  111. package/dist/standalone/worker-routes.d.ts +19 -0
  112. package/dist/standalone/worker-routes.js +9 -0
  113. package/dist/standalone/worker.d.ts +1 -1
  114. package/dist/standalone/worker.js +27 -35
  115. package/dist/standalone/workflow-binding.d.ts +10 -3
  116. package/dist/standalone/workflow-binding.js +22 -5
  117. package/dist/standalone/workflow-git-capture.js +2 -1
  118. package/dist/standalone/workflow-host.d.ts +8 -103
  119. package/dist/standalone/workflow-host.js +1 -1
  120. package/dist/standalone/workflow-node-capture.js +2 -2
  121. package/dist/standalone/workflow-root-authority.d.ts +1 -1
  122. package/dist/standalone/workflow-root-authority.js +1 -1
  123. package/dist/standalone/workflow-schedules.d.ts +31 -4
  124. package/dist/standalone/workflow-schedules.js +18 -5
  125. package/dist/standalone/workflow-webhooks.d.ts +4 -18
  126. package/dist/standalone/workflow-webhooks.js +5 -5
  127. package/dist/standalone/workflows.d.ts +53 -27
  128. package/dist/standalone/workflows.js +9 -3
  129. package/dist/storage-frame.d.ts +6 -6
  130. package/dist/testing/capabilities.d.ts +7 -6
  131. package/dist/testing/capabilities.js +3 -6
  132. package/dist/testing/installation.d.ts +0 -1
  133. package/dist/testing/installation.js +2 -5
  134. package/dist/workers-git.d.ts +12 -4
  135. package/dist/workers-git.js +38 -35
  136. package/dist/workers.d.ts +9 -1
  137. package/dist/workers.js +44 -8
  138. package/dist/workflows.d.ts +4 -0
  139. package/dist/workflows.js +2 -0
  140. package/docs/api/README.md +7 -0
  141. package/docs/api/agent-schedules.md +176 -0
  142. package/docs/api/agents.md +34 -13
  143. package/docs/api/app-storage.md +1 -1
  144. package/docs/api/apps.md +323 -225
  145. package/docs/api/auth.md +0 -55
  146. package/docs/api/authorization.md +1300 -0
  147. package/docs/api/build.md +42 -38
  148. package/docs/api/cloudflare.md +3 -3
  149. package/docs/api/git.md +20 -11
  150. package/docs/api/host.md +64 -192
  151. package/docs/api/index.md +1 -1
  152. package/docs/api/invocation.md +14 -14
  153. package/docs/api/keys.md +1 -98
  154. package/docs/api/mcp.md +43 -40
  155. package/docs/api/modules.md +2 -0
  156. package/docs/api/page/mount.md +16 -19
  157. package/docs/api/workers-git.md +46 -12
  158. package/docs/api/workers.md +111 -222
  159. package/package.json +41 -22
  160. package/dist/page/computer-target.d.ts +0 -3
  161. package/dist/page/computer-target.js +0 -9
  162. package/dist/standalone/channel-session-door.d.ts +0 -4
  163. package/dist/standalone/channel-session-door.js +0 -72
package/dist/host.js CHANGED
@@ -11,8 +11,7 @@ import { appStorageKeyNameSchema, appStorageWriteSchema, appStorageResetSchema,
11
11
  import { guestLimits } from "./guest-policy.js";
12
12
  import { APP_INVOCATION_CEILING_MS, admittedCallerFacts, admittedMethodCallerFacts, invocationAuthorityOf, invocationCallerContext, invocationFacts, } from "./invocation.js";
13
13
  import { appFacetResponse } from "./backend-request.js";
14
- import { Hono } from "hono";
15
- import { endTime, startTime, timing } from "hono/timing";
14
+ import { endTime, startTime } from "hono/timing";
16
15
  import { bodyLimit } from "hono/body-limit";
17
16
  import { match } from "path-to-regexp";
18
17
  import * as Schema from "effect/Schema";
@@ -21,7 +20,7 @@ import { emptyAppDeclaration } from "@fungi.computer/puffball/app";
21
20
  import { applyInstallationCommand, installationIdSchema, } from "./apps/installation.js";
22
21
  import { admits, scopedBindings, grantsFor } from "./apps/gate.js";
23
22
  import { appPermissionPageSchema, permissionSaveSchema, } from "./apps/permissions.js";
24
- import { keyAppAccess } from "./keys.js";
23
+ import { app as appRoute, apps as appsRoute, door, member, oysterPrincipalIsOwner, owner, publicRoute, authorityOf, hostAuthorityPorts, reachesInstallation, routes, serveCapability, serveHost, } from "./authorization.js";
25
24
  import { deriveDeclaredOperations, appCallOperation, } from "./apps/operations.js";
26
25
  import { appBackendActorHeader, appBackendComputerHeader, appMethodRefusalStatus, } from "@fungi.computer/agar/backend";
27
26
  import { appCallsDescriptor, } from "./capability-descriptor.js";
@@ -40,15 +39,8 @@ import { Form, Field, FormFooter } from "@fungi.computer/caps/components/field";
40
39
  import { Input } from "@fungi.computer/caps/components/input";
41
40
  import { Button } from "@fungi.computer/caps/components/button";
42
41
  import { parseArtifactRef } from "@fungi.computer/puffball";
42
+ import { isBuildJobLogRef } from "./build/job-log.js";
43
43
  export { ReleaseError, defaultAssetPolicy, parseRelease, resolveBackend, sha256, verifyRelease, } from "./release.js";
44
- /** Narrow an admitted principal to the anonymous public reader. */
45
- export function oysterPrincipalIsVisitor(principal) {
46
- return principal.actor.kind === "visitor";
47
- }
48
- /** True only for a member admitted with the owner role. */
49
- export function oysterPrincipalIsOwner(principal) {
50
- return "role" in principal && principal.role === "owner";
51
- }
52
44
  /** Stable actor identity for host storage and backend attribution. */
53
45
  export function oysterPrincipalId(principal) {
54
46
  switch (principal.actor.kind) {
@@ -87,9 +79,6 @@ function escapeHtml(value) {
87
79
  .replaceAll(">", ">")
88
80
  .replaceAll('"', """);
89
81
  }
90
- const visibilitySchema = Schema.Struct({
91
- visibility: Schema.Literals(["private", "public"]),
92
- });
93
82
  function refusal(status, error, retryAfter) {
94
83
  const init = { status };
95
84
  if (retryAfter !== undefined)
@@ -138,8 +127,8 @@ function methodResponse(reply) {
138
127
  * designated install authority.
139
128
  */
140
129
  async function admitInstallation(host, installationId, release, principal, intent, origin, requestId = installationCommandIds.request.generate()) {
141
- // App-mediated installation needs installFree/designated-marketplace
142
- // authority. A member's owner role is not that delegated App authority.
130
+ // App-mediated installation has no owner-confirmation flow here, so refuse
131
+ // it. A member's owner role is not delegated App install authority.
143
132
  if (origin !== "owner-direct" && origin !== "workflow")
144
133
  return refusal(403, "install_authority_unavailable");
145
134
  if (!oysterPrincipalIsOwner(principal))
@@ -195,27 +184,32 @@ export async function installOrSelect(host, installationId, release, principal,
195
184
  /** The tab icon of every Oyster page a person opens at the top level. */
196
185
  const tabIcon = `<link rel="icon" type="image/svg+xml" href="${fungiFaviconHref}">`;
197
186
  /**
198
- * Create the Oyster App server: a Hono app that installs App Releases, serves
187
+ * Create the Oyster App server: a served route table that installs App Releases, serves
199
188
  * their UI, forwards their backend requests and methods to their Instances,
200
189
  * and mounts the host's MCP endpoint. The host's adapters decide where state
201
190
  * lives and who may sign in.
202
191
  */
203
192
  export function createOyster(host) {
193
+ return oysterRouteTable(host).serve();
194
+ }
195
+ /**
196
+ * The Oyster host's declared route table: every route names its requirement
197
+ * (`authorization.ts`), so an undeclared route is never served.
198
+ */
199
+ export function oysterRouteTable(host) {
204
200
  const limits = guestLimits(host.guestLimits);
205
- const app = new Hono();
206
- // Server-Timing names the Durable Object hops a request waited on.
207
- // An upgraded socket keeps its response untouched.
208
- app.use(timing({
209
- totalDescription: "Oyster",
210
- enabled: (context) => context.res.status !== 101,
211
- }));
212
- // A forwarded Durable Object or fetch response has immutable headers;
213
- // give the timing middleware a copy it can append Server-Timing to.
214
- app.use(async (context, next) => {
215
- await next();
216
- if (context.res.status !== 101)
217
- context.res = new Response(context.res.body, context.res);
218
- });
201
+ const table = routes();
202
+ // One authentication per request, shared by its requirement and its door.
203
+ const authentications = new WeakMap();
204
+ const authenticate = (request) => {
205
+ let result = authentications.get(request);
206
+ if (result === undefined) {
207
+ result = host.auth.authenticate(request);
208
+ authentications.set(request, result);
209
+ }
210
+ return result;
211
+ };
212
+ const ports = hostAuthorityPorts(host);
219
213
  const releaseOf = releaseCache(host.artifacts);
220
214
  const operationDescriptors = host.capabilities ?? [];
221
215
  const gateFacts = (installation, release, principal) => hostGateFacts(host, installation, release, principal);
@@ -248,17 +242,15 @@ export function createOyster(host) {
248
242
  source,
249
243
  });
250
244
  const prepareAssets = async () => {
251
- const { pageScript, publicHomeScript, grantConsentScript, fontStyles, loginFontStyles, shellStyles, } = await (host.pageAssets ?? loadPageAssets)();
245
+ const { pageScript, grantConsentScript, fontStyles, loginFontStyles, shellStyles, } = await (host.pageAssets ?? loadPageAssets)();
252
246
  return Promise.all([
253
247
  asset("page.js", "text/javascript; charset=utf-8", host.pageScript ?? pageScript),
254
- asset("public-home.js", "text/javascript; charset=utf-8", publicHomeScript),
255
248
  asset("grant-consent.js", "text/javascript; charset=utf-8", grantConsentScript),
256
249
  asset("fonts.css", "text/css; charset=utf-8", fontStyles),
257
250
  asset("login-fonts.css", "text/css; charset=utf-8", loginFontStyles),
258
251
  asset("styles.css", "text/css; charset=utf-8", shellStyles),
259
- ]).then(([page, publicHome, grantConsent, fonts, loginFonts, styles]) => ({
252
+ ]).then(([page, grantConsent, fonts, loginFonts, styles]) => ({
260
253
  page,
261
- publicHome,
262
254
  grantConsent,
263
255
  fonts,
264
256
  loginFonts,
@@ -275,22 +267,10 @@ export function createOyster(host) {
275
267
  // An Oyster is a private instance, never a search result: every response
276
268
  // says so, and robots.txt is public so crawlers read the rule rather than a
277
269
  // 401 (which Google treats as "crawl everything").
278
- app.use("*", async (context, next) => {
279
- await next();
280
- try {
281
- context.res.headers.set("X-Robots-Tag", "noindex, nofollow");
282
- }
283
- catch (cause) {
284
- // Fetched responses (proxied Apps, the git door) have immutable headers.
285
- if (!(cause instanceof TypeError))
286
- throw cause;
287
- const { body, status, statusText, headers } = context.res;
288
- const marked = new Headers(headers);
289
- marked.set("X-Robots-Tag", "noindex, nofollow");
290
- context.res = new Response(body, { status, statusText, headers: marked });
291
- }
270
+ const noIndex = () => ({
271
+ "X-Robots-Tag": "noindex, nofollow",
292
272
  });
293
- app.get("/robots.txt", () => new Response("User-agent: *\nDisallow: /\n", {
273
+ table.get("/robots.txt", publicRoute("the crawl rule for a private instance"), () => new Response("User-agent: *\nDisallow: /\n", {
294
274
  headers: {
295
275
  "Content-Type": "text/plain; charset=utf-8",
296
276
  "Cache-Control": "public, max-age=3600",
@@ -300,7 +280,7 @@ export function createOyster(host) {
300
280
  // own URL: released Oyster 0.2.0 served that URL immutable, so a browser may
301
281
  // hold a copy it would import without a request. The entry is never cached
302
282
  // and rechecks current approval before it names the module.
303
- app.get(`/ui-entry/:installationId/:releaseId${shellModuleAssetPath}`, async (context) => {
283
+ table.get(`/ui-entry/:installationId/:releaseId${shellModuleAssetPath}`, publicRoute("never cached; rechecks the shell's approval before naming it"), async (context) => {
304
284
  const installation = await host.installations.get(context.req.param("installationId"));
305
285
  if (installation?.status !== "enabled" ||
306
286
  installation.release.releaseId !== context.req.param("releaseId"))
@@ -322,7 +302,7 @@ export function createOyster(host) {
322
302
  });
323
303
  // App UI: digest-pinned, sandboxed, and readable without a session because
324
304
  // the opaque-origin frame cannot carry one.
325
- app.get("/ui/:installationId/:releaseId/*", async (context) => {
305
+ table.get("/ui/:installationId/:releaseId/*", publicRoute("digest-pinned App bytes for an opaque-origin frame"), async (context) => {
326
306
  startTime(context, "registry");
327
307
  const installation = await host.installations.get(context.req.param("installationId"));
328
308
  endTime(context, "registry");
@@ -373,7 +353,7 @@ export function createOyster(host) {
373
353
  },
374
354
  });
375
355
  });
376
- app.get("/_oyster/assets/*", async (context) => {
356
+ table.get("/_oyster/assets/*", publicRoute("content-addressed host page assets"), async (context) => {
377
357
  const wanted = new URL(context.req.url).pathname;
378
358
  const found = Object.values(await assets()).find(({ path }) => path === wanted);
379
359
  if (found === undefined)
@@ -386,7 +366,7 @@ export function createOyster(host) {
386
366
  },
387
367
  });
388
368
  });
389
- app.get("/login", async () => {
369
+ table.get("/login", publicRoute("the sign-in form"), async () => {
390
370
  const { loginFonts, styles } = await assets();
391
371
  const form = renderToStaticMarkup(createElement(CenteredCard, null, createElement(CardHeader, null, createElement(CardTitle, { level: 2 }, "Sign in")), createElement(CardContent, null, createElement(Form, { method: "post", action: "/login" }, createElement(Field, {
392
372
  label: "Owner key",
@@ -405,16 +385,16 @@ export function createOyster(host) {
405
385
  },
406
386
  });
407
387
  });
408
- app.post("/login", (context) => host.auth.login === undefined
388
+ table.post("/login", door("the host's own sign-in checks the posted key"), (context) => host.auth.login === undefined
409
389
  ? refusal(404, "Sign-in is not available")
410
390
  : host.auth.login(context.req.raw));
411
391
  if (host.mcp !== undefined) {
412
392
  const mcp = host.mcp;
413
- app.all("/mcp", (context) => mcp.handleRequest(context.req.raw));
393
+ table.all("/mcp", door("MCP authenticates every request; tools read this module's reach"), (context) => mcp.handleRequest(context.req.raw));
414
394
  }
415
395
  if (host.git !== undefined) {
416
396
  const git = host.git;
417
- app.route("/", createGitDoor({
397
+ const gitDoor = createGitDoor({
418
398
  authenticate: async (request, target) => {
419
399
  // A Workflow Git pass header is admitted only by the pass port,
420
400
  // never as a session or key.
@@ -430,80 +410,27 @@ export function createOyster(host) {
430
410
  return git.buildPass === undefined
431
411
  ? null
432
412
  : git.buildPass(presented.value, target);
433
- const result = await host.auth.authenticate(request);
413
+ // A signed-in key reaches only the installations its grants
414
+ // name: clone needs App read, push App use. Otherwise the door
415
+ // refuses it.
416
+ const result = await authenticate(request);
434
417
  if (result.kind !== "admitted")
435
418
  return null;
436
- // A named key signs Git in only where its own grants reach that
437
- // App: a clone needs App read, a push App use. Otherwise it is
438
- // anonymous, which reaches only a public repository's reads.
439
- const { actor } = result.principal;
440
- if (actor.kind === "team-key") {
441
- const key = (await host.keys?.live(actor.keyId)) ?? null;
442
- const access = key === null ? null : keyAppAccess(key, target.installationId);
443
- if (access === null ||
444
- (target.scope === "write" && access.level !== "write"))
445
- return null;
446
- }
447
- return git.writerOf(result.principal);
419
+ const installation = await host.installations.get(target.installationId);
420
+ return installation !== null &&
421
+ (await reachesInstallation(await authorityOf(result.principal, ports), installation, target.scope === "write" ? "use" : "read", ports))
422
+ ? git.writerOf(result.principal)
423
+ : null;
448
424
  },
449
- isPublic: async (installationId) => (await host.installations.get(installationId))?.visibility ===
450
- "public",
451
425
  git: async (installationId) => {
452
426
  const installation = await host.installations.get(installationId);
453
427
  if (installation?.status !== "enabled")
454
428
  return null;
455
429
  return git.git(installation);
456
430
  },
457
- }));
431
+ });
432
+ table.all("/git/*", door("Git passes and keys; a key's reach is checked per installation"), (context) => gitDoor.fetch(context.req.raw));
458
433
  }
459
- // A public installation's page and read methods admit anonymous visitors.
460
- const visitor = { actor: { kind: "visitor" } };
461
- const visitorRoute = /^\/apps\/([^/]+)\/(?:|methods\/([A-Za-z0-9_.]+))$/u;
462
- const visitorMayReach = async (request) => {
463
- const match = visitorRoute.exec(new URL(request.url).pathname);
464
- if (match === null)
465
- return null;
466
- const [, installationId = "", method] = match;
467
- const page = method === undefined;
468
- if (page ? request.method !== "GET" : request.method !== "POST")
469
- return null;
470
- const installation = await host.installations.get(installationId);
471
- if (installation?.status !== "enabled" ||
472
- installation.visibility !== "public")
473
- return null;
474
- if (page)
475
- return installation;
476
- const release = await releaseOf(installation.release);
477
- return release.api?.methods[method]?.effect === "read"
478
- ? installation
479
- : null;
480
- };
481
- // The public home and public App reads admit visitors; other routes require authentication.
482
- app.use("*", async (context, next) => {
483
- const result = await host.auth.authenticate(context.req.raw);
484
- const installation = result.kind === "refused" ? await visitorMayReach(context.req.raw) : null;
485
- if (result.kind === "refused" &&
486
- (result.status === 401 || result.status === 403) &&
487
- ((context.req.method === "GET" && context.req.path === "/") ||
488
- installation !== null)) {
489
- if (installation !== null)
490
- context.set("installation", installation);
491
- context.set("principal", visitor);
492
- await next();
493
- return undefined;
494
- }
495
- if (result.kind === "refused") {
496
- const accept = context.req.header("Accept") ?? "";
497
- if ((result.status === 401 || result.status === 403) &&
498
- context.req.method === "GET" &&
499
- accept.includes("text/html"))
500
- return context.redirect("/login");
501
- return refusal(result.status, "Sign in to use this Oyster", result.retryAfter);
502
- }
503
- context.set("principal", result.principal);
504
- await next();
505
- return undefined;
506
- });
507
434
  const capabilityRoutes = (host.capabilities ?? []).flatMap((capability) => Object.entries(capability.methods).map(([name, method]) => ({
508
435
  capability,
509
436
  operation: `route:${capability.id}:${name}`,
@@ -514,17 +441,26 @@ export function createOyster(host) {
514
441
  sensitive: true,
515
442
  }),
516
443
  })));
517
- // Capability routes run under the principal stamped above; they receive the
518
- // admission as their env and never authenticate again.
519
- app.use("*", async (context, next) => {
444
+ // Each capability's own declared table, served once; it reads the
445
+ // dispatcher's admission from its env and never authenticates again.
446
+ const capabilityTables = new Map();
447
+ const capabilityTable = (capability) => {
448
+ let served = capabilityTables.get(capability.routes);
449
+ if (served === undefined) {
450
+ served = serveCapability(capability.routes, ports);
451
+ capabilityTables.set(capability.routes, served);
452
+ }
453
+ return served;
454
+ };
455
+ // Each declared capability path is a route: the App must hold that
456
+ // capability for the call, then the capability's own route decides the
457
+ // resource (an Agent, the Computer) under the same principal.
458
+ const dispatchCapability = async (context, principal) => {
520
459
  const url = new URL(context.req.url);
521
460
  const candidates = capabilityRoutes.filter((route) => (route.method === "ANY" || route.method === context.req.method) &&
522
461
  route.matches(url.pathname));
523
462
  if (candidates.length === 0)
524
- return next();
525
- const principal = context.get("principal");
526
- if (oysterPrincipalIsVisitor(principal))
527
- return refusal(401, "Sign in to use capabilities");
463
+ return refusal(404, "Not found");
528
464
  const browserTransport = context.req.header("Upgrade")?.toLowerCase() === "websocket" &&
529
465
  context.req.header("Origin") === url.origin &&
530
466
  candidates.some((route) => route.kind === "transport");
@@ -548,57 +484,45 @@ export function createOyster(host) {
548
484
  }).kind === "refused")
549
485
  continue;
550
486
  url.pathname = `/${url.pathname.slice(capability.base.length)}`;
551
- return capability.routes.fetch(new Request(url, context.req.raw), {
552
- principal,
553
- installation,
554
- });
487
+ return capabilityTable(capability).fetch(new Request(url, context.req.raw), { principal, installation });
555
488
  }
556
489
  return refusal(403, "Capability permission denied");
557
- });
558
- app.get("/", async (context) => {
559
- const actor = context.get("principal").actor;
560
- const publicHome = actor.kind === "visitor";
561
- const key = actor.kind === "team-key"
562
- ? ((await host.keys?.live(actor.keyId)) ?? null)
563
- : null;
564
- if (!publicHome && host.homeInstallationId !== undefined) {
490
+ };
491
+ const capabilityPaths = new Set((host.capabilities ?? []).flatMap((capability) => Object.values(capability.methods).flatMap((method) => {
492
+ const path = `${capability.base}${method.path}`;
493
+ return method.kind === "transport" ? [path, `${path}/*`] : [path];
494
+ })));
495
+ for (const path of capabilityPaths)
496
+ table.all(path, appRoute(), (context, admitted) => dispatchCapability(context, admitted.principal));
497
+ table.get("/", appsRoute(), async (context, admitted) => {
498
+ if (host.homeInstallationId !== undefined) {
565
499
  const home = await host.installations.get(host.homeInstallationId);
566
- if (home?.status === "enabled" &&
567
- (actor.kind !== "team-key" ||
568
- (key !== null && keyAppAccess(key, home.installationId) !== null)))
569
- return context.redirect(`/apps/${encodeURIComponent(home.installationId)}/`);
570
- }
571
- const installations = (await host.installations.list()).filter((installation) => installation.status === "enabled" &&
572
- (!publicHome || installation.visibility === "public") &&
573
- (actor.kind !== "team-key" ||
574
- (key !== null &&
575
- keyAppAccess(key, installation.installationId) !== null)));
576
- if (publicHome) {
577
- const apps = [];
578
- for (const installation of installations) {
579
- const release = await releaseOf(installation.release);
580
- if (release.ui === null)
581
- continue;
582
- apps.push({
583
- href: `/apps/${encodeURIComponent(installation.installationId)}/`,
584
- displayName: release.declaration?.displayName ?? "App",
585
- icon: release.declaration?.icon ?? null,
586
- });
500
+ if (home?.status === "enabled" && (await admitted.reaches(home))) {
501
+ const release = await releaseOf(home.release);
502
+ if (release.declaration?.capabilities.includes("team.workbenchHost") !==
503
+ true ||
504
+ (await shellApproved(home, release)))
505
+ return context.redirect(`/apps/${encodeURIComponent(home.installationId)}/`);
506
+ // Revocation must leave an owner a way back to consent, rather than
507
+ // redirecting into a shell that can no longer offer Settings.
587
508
  }
588
- const config = JSON.stringify({ title, apps }).replaceAll("<", "\\u003c");
589
- return new Response(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(title)}</title>${tabIcon}<link rel="stylesheet" href="${(await assets()).fonts.path}"><style>html,body{margin:0;min-height:100%;font-family:var(--font-sans);background:var(--color-base-100);color:var(--color-base-content)}#oyster-public-home{min-height:100vh;display:flex;flex-direction:column}</style><div id="oyster-public-home"></div><script type="application/json" id="oyster-public-config">${config}</script><script type="module" src="${(await assets()).publicHome.path}"></script>`, {
590
- headers: {
591
- "Content-Type": "text/html; charset=utf-8",
592
- "Cache-Control": "no-store",
593
- "X-Content-Type-Options": "nosniff",
594
- "Content-Security-Policy": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src data:; img-src data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
595
- },
596
- });
597
509
  }
510
+ const installations = [];
511
+ const stored = await host.installations.list();
512
+ for (const installation of stored)
513
+ if (installation.status === "enabled" &&
514
+ (await admitted.reaches(installation)))
515
+ installations.push(installation);
598
516
  const items = installations
599
- .map((installation) => `<li><a href="/apps/${escapeHtml(installation.installationId)}/">${escapeHtml(installation.installationId)}</a> <small>${escapeHtml(installation.release.releaseId)}</small>${oysterPrincipalIsOwner(context.get("principal")) ? ` <a href="/api/installations/${encodeURIComponent(installation.installationId)}/grants">App access</a>` : ""}</li>`)
517
+ .map((installation) => `<li><a href="/apps/${escapeHtml(installation.installationId)}/">${escapeHtml(installation.installationId)}</a> <small>${escapeHtml(installation.release.releaseId)}</small>${admitted.authority.kind === "owner" ? ` <a href="/api/installations/${encodeURIComponent(installation.installationId)}/grants">App access</a>` : ""}</li>`)
600
518
  .join("");
601
- return new Response(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(title)}</title>${tabIcon}<h1>${escapeHtml(title)}</h1><ul>${items || "<li>No Apps installed yet.</li><li>Install Apps with <code>npx oyster install-apps</code></li>"}</ul>`, {
519
+ const resetAt = await host.installations.resetAt?.();
520
+ const resetNotice = resetAt !== undefined &&
521
+ resetAt !== null &&
522
+ !stored.some((installation) => installation.status === "enabled")
523
+ ? "<p>This update reset your installed Apps. Reinstall them with npx oyster install-apps.</p>"
524
+ : "";
525
+ return new Response(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(title)}</title>${tabIcon}<h1>${escapeHtml(title)}</h1>${resetNotice}<ul>${items || "<li>No Apps installed yet.</li><li>Install Apps with <code>npx oyster install-apps</code></li>"}</ul>`, {
602
526
  headers: {
603
527
  "Content-Type": "text/html; charset=utf-8",
604
528
  "Cache-Control": "no-store",
@@ -606,51 +530,9 @@ export function createOyster(host) {
606
530
  },
607
531
  });
608
532
  });
609
- app.use("/api/*", async (context, next) => {
610
- if (!oysterPrincipalIsOwner(context.get("principal")))
611
- return refusal(403, "Only the owner manages this Oyster");
612
- await next();
613
- return undefined;
614
- });
615
- app.put("/api/installations/:installationId/visibility", async (context) => {
616
- const generation = await host.installations.generation();
617
- const current = await host.installations.get(context.req.param("installationId"));
618
- if (current === null || current.status === "deleted")
619
- return refusal(404, "Not found");
620
- const body = Schema.decodeUnknownOption(visibilitySchema)(await context.req.json());
621
- if (body._tag === "None")
622
- return refusal(400, "Invalid visibility");
623
- const update = await reduceInstallation(current, { kind: "set-visibility", visibility: body.value.visibility }, await releaseOf(current.release), context.get("principal"), current);
624
- if (Result.isFailure(update))
625
- return refusal(409, update.failure.code);
626
- const next = update.success.next;
627
- const release = await releaseOf(current.release);
628
- const refused = await prepare(next, release);
629
- if (refused !== null)
630
- return refused;
631
- const commit = await host.installations.compareAndPut(next, current.authority.authorityVersion, generation);
632
- if (!commit.ok)
633
- return refusal(409, commit.code);
634
- const activationRefusal = await apply(next, release);
635
- if (activationRefusal !== null)
636
- return activationRefusal;
637
- return Response.json(next);
638
- });
639
- if (host.ownerRoutes !== undefined) {
640
- const ownerRoutes = host.ownerRoutes;
641
- const owned = new Hono();
642
- owned.use("/api/*", async (context, next) => {
643
- if (!oysterPrincipalIsOwner(context.get("principal")))
644
- return refusal(403, "Owner only");
645
- await next();
646
- return undefined;
647
- });
648
- owned.route("/", ownerRoutes);
649
- app.route("/", owned);
650
- }
651
- app.get("/api/workbench/directory", async (context) => {
652
- if (!oysterPrincipalIsOwner(context.get("principal")))
653
- return refusal(403, "Owner only");
533
+ if (host.ownerRoutes !== undefined)
534
+ table.include(host.ownerRoutes);
535
+ table.get("/api/workbench/directory", owner("direct-or-app"), async (context) => {
654
536
  if (host.workbench === undefined)
655
537
  return refusal(404, "Not found");
656
538
  startTime(context, "registry");
@@ -665,10 +547,7 @@ export function createOyster(host) {
665
547
  headers: { "Cache-Control": "no-store" },
666
548
  });
667
549
  });
668
- app.all("/api/workbench/storage/:installationId/:operation", async (context) => {
669
- const principal = context.get("principal");
670
- if (!oysterPrincipalIsOwner(principal))
671
- return refusal(403, "Owner only");
550
+ table.all("/api/workbench/storage/:installationId/:operation", owner("direct-or-app"), async (context, { principal }) => {
672
551
  const workbench = host.workbench;
673
552
  if (workbench === undefined)
674
553
  return refusal(404, "Not found");
@@ -702,8 +581,8 @@ export function createOyster(host) {
702
581
  }
703
582
  return refusal(404, "Not found");
704
583
  });
705
- app.get("/api/installations", async () => Response.json(await host.installations.list()));
706
- app.put("/api/artifacts/:artifactRef", async (context) => {
584
+ table.get("/api/installations", owner("direct-or-app"), async () => Response.json(await host.installations.list()));
585
+ table.put("/api/artifacts/:artifactRef", owner("direct-or-app"), async (context) => {
707
586
  let artifactRef;
708
587
  try {
709
588
  artifactRef = parseArtifactRef(context.req.param("artifactRef"));
@@ -711,6 +590,8 @@ export function createOyster(host) {
711
590
  catch {
712
591
  return refusal(400, "Invalid artifact reference");
713
592
  }
593
+ if (isBuildJobLogRef(artifactRef))
594
+ return refusal(409, "build_job_log_ref_reserved");
714
595
  const expected = context.req.query("digest");
715
596
  const bytes = new Uint8Array(await context.req.arrayBuffer());
716
597
  if (expected === undefined || (await sha256(bytes)) !== expected)
@@ -734,7 +615,7 @@ export function createOyster(host) {
734
615
  .apply({ ...selectionOf(installation), status: installation.status }, installation.release);
735
616
  return result.ok ? null : instanceRefusal(result.code, result.causeClass);
736
617
  };
737
- app.post("/api/installations", async (context) => {
618
+ table.post("/api/installations", owner("direct-or-app"), async (context, { origin, principal }) => {
738
619
  let request;
739
620
  let release;
740
621
  try {
@@ -750,17 +631,12 @@ export function createOyster(host) {
750
631
  const key = installationRequestIdSchema.safeParse(context.req.header("Idempotency-Key"));
751
632
  if (!key.success)
752
633
  return refusal(400, "Invalid installation request identity");
753
- const admitted = await admitInstallation(host, installationId, release, context.get("principal"), "install-or-remint", context.req.raw.headers.has("X-Oyster-Installation")
754
- ? "app"
755
- : "owner-direct", key.data);
634
+ const admitted = await admitInstallation(host, installationId, release, principal, "install-or-remint", origin, key.data);
756
635
  return admitted instanceof Response
757
636
  ? admitted
758
637
  : Response.json(admitted, { status: 202 });
759
638
  });
760
- app.get("/api/installations/:installationId/status", async (context) => {
761
- if (!oysterPrincipalIsOwner(context.get("principal")) ||
762
- context.req.raw.headers.has("X-Oyster-Installation"))
763
- return refusal(403, "Owner only");
639
+ table.get("/api/installations/:installationId/status", owner("direct"), async (context) => {
764
640
  const key = installationRequestIdSchema.safeParse(context.req.query("requestId"));
765
641
  if (!key.success)
766
642
  return refusal(400, "Invalid installation request identity");
@@ -779,9 +655,7 @@ export function createOyster(host) {
779
655
  throw cause;
780
656
  }
781
657
  });
782
- app.get("/api/installations/:installationId/grants", async (context) => {
783
- if (context.req.raw.headers.has("X-Oyster-Installation"))
784
- return refusal(403, "Owner direct only");
658
+ table.get("/api/installations/:installationId/grants", owner("direct"), async (context, { principal }) => {
785
659
  const generation = await host.installations.generation();
786
660
  const current = await host.installations.get(context.req.param("installationId"));
787
661
  if (current === null || current.status === "deleted")
@@ -795,6 +669,18 @@ export function createOyster(host) {
795
669
  appId: current.appId,
796
670
  appName: declaration.displayName ?? current.installationId,
797
671
  artifactDigest: current.release.artifact.artifactDigest,
672
+ canEdit: oysterPrincipalIsOwner(principal),
673
+ resources: host.permissionResources === undefined
674
+ ? host.computerId === undefined
675
+ ? []
676
+ : [
677
+ {
678
+ kind: "computer",
679
+ id: host.computerId,
680
+ label: host.computerId,
681
+ },
682
+ ]
683
+ : await host.permissionResources(principal),
798
684
  permissions: {
799
685
  authority: current.authority,
800
686
  declaredOperations: declared,
@@ -810,20 +696,13 @@ export function createOyster(host) {
810
696
  "Content-Type": "text/html; charset=utf-8",
811
697
  "Cache-Control": "no-store",
812
698
  "X-Content-Type-Options": "nosniff",
813
- "Content-Security-Policy": "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
699
+ "Content-Security-Policy": "default-src 'none'; script-src 'self'; connect-src 'self'; style-src 'self' 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'",
814
700
  },
815
701
  });
816
702
  });
817
- app.put("/api/installations/:installationId/grants", bodyLimit({
818
- maxSize: 64 * 1024,
819
- onError: () => refusal(413, "Approval request too large"),
820
- }), async (context) => {
821
- // Capability-origin requests cannot become direct owner consent.
822
- if (context.req.raw.headers.has("X-Oyster-Installation"))
823
- return refusal(403, "Owner direct only");
824
- const principal = context.get("principal");
825
- if (!oysterPrincipalIsOwner(principal))
826
- return refusal(403, "Owner only");
703
+ table.put("/api/installations/:installationId/grants",
704
+ // Capability-origin requests cannot become direct owner consent.
705
+ owner("direct"), async (context, { principal }) => {
827
706
  const body = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(await context.req.text());
828
707
  if (body._tag === "None")
829
708
  return refusal(400, "Invalid approval");
@@ -857,8 +736,15 @@ export function createOyster(host) {
857
736
  if (refused !== null)
858
737
  return refused;
859
738
  return Response.json(update.success.next);
739
+ }, {
740
+ middleware: [
741
+ bodyLimit({
742
+ maxSize: 64 * 1024,
743
+ onError: () => refusal(413, "Approval request too large"),
744
+ }),
745
+ ],
860
746
  });
861
- app.put("/api/installations/:installationId/release", async (context) => {
747
+ table.put("/api/installations/:installationId/release", owner("direct-or-app"), async (context, admitted) => {
862
748
  let release;
863
749
  try {
864
750
  release = parseRelease(await context.req.json());
@@ -872,16 +758,14 @@ export function createOyster(host) {
872
758
  return refusal(400, "Invalid installation request identity");
873
759
  // PUT retains its completed-response meaning. Cancellation only stops this
874
760
  // observer; Watchdog's retained command continues independently.
875
- return installOrSelect(host, context.req.param("installationId"), release, context.get("principal"), "install-or-select", context.req.raw.headers.has("X-Oyster-Installation")
876
- ? "app"
877
- : "owner-direct", key.data, context.req.raw.signal);
761
+ return installOrSelect(host, context.req.param("installationId"), release, admitted.principal, "install-or-select", admitted.origin, key.data, context.req.raw.signal);
878
762
  });
879
- app.delete("/api/installations/:installationId", async (context) => {
763
+ table.delete("/api/installations/:installationId", owner("direct-or-app"), async (context, admitted) => {
880
764
  const generation = await host.installations.generation();
881
765
  const current = await host.installations.get(context.req.param("installationId"));
882
766
  if (current === null || current.status === "deleted")
883
767
  return refusal(404, "Not found");
884
- const update = await reduceInstallation(current, { kind: "set-status", status: "deleted" }, await releaseOf(current.release), context.get("principal"), current);
768
+ const update = await reduceInstallation(current, { kind: "set-status", status: "deleted" }, await releaseOf(current.release), admitted.principal, current);
885
769
  if (Result.isFailure(update))
886
770
  return refusal(409, update.failure.code);
887
771
  const next = update.success.next;
@@ -899,21 +783,18 @@ export function createOyster(host) {
899
783
  });
900
784
  const enabled = async (context) => {
901
785
  const installationId = context.req.param("installationId") ?? "";
902
- const resolved = context.get("installation");
903
- if (resolved?.installationId === installationId)
904
- return resolved.status === "enabled" ? resolved : null;
905
786
  startTime(context, "registry");
906
787
  const installation = await host.installations.get(installationId);
907
788
  endTime(context, "registry");
908
789
  return installation?.status === "enabled" ? installation : null;
909
790
  };
910
- app.get("/apps/:installationId/", async (context) => {
791
+ table.get("/apps/:installationId/", appRoute(), async (context, admitted) => {
911
792
  const installation = await enabled(context);
912
793
  if (installation === null)
913
794
  return refusal(404, "Not found");
914
795
  const release = await releaseOf(installation.release);
915
796
  const declaration = release.declaration;
916
- const principal = context.get("principal");
797
+ const principal = admitted.principal;
917
798
  const facts = await gateFacts(installation, release, principal);
918
799
  if (principal.actor.kind === "team-key" && facts.access === null)
919
800
  return refusal(403, "App access denied");
@@ -988,7 +869,7 @@ export function createOyster(host) {
988
869
  // desktop chrome belongs to the shell.
989
870
  return new Response(config.workbench
990
871
  ? `${head}<style>html,body,#oyster-shell{margin:0;height:100%}body{font-family:var(--font-sans)}</style><div id="oyster-shell"></div>${tail}`
991
- : `${head}<style>html,body{margin:0;height:100%}iframe{border:0;width:100%;height:100%;display:block}</style><iframe id="oyster-app" sandbox="${appSandbox}" src="${escapeHtml(config.uiUrl)}" title="${escapeHtml(installation.installationId)}"></iframe>${tail}`, {
872
+ : `${head}<style>html,body{margin:0;height:100%}iframe{border:0;width:100%;height:100%;display:block}</style><iframe id="oyster-app" sandbox="${appSandbox}" allowfullscreen src="${escapeHtml(config.uiUrl)}" title="${escapeHtml(installation.installationId)}"></iframe>${tail}`, {
992
873
  headers: {
993
874
  "Content-Type": "text/html; charset=utf-8",
994
875
  "Cache-Control": "no-store",
@@ -998,10 +879,7 @@ export function createOyster(host) {
998
879
  },
999
880
  });
1000
881
  });
1001
- app.put("/apps/:installationId/backend", async (context) => {
1002
- const principal = context.get("principal");
1003
- if (oysterPrincipalIsVisitor(principal))
1004
- return refusal(403, "Sign in to use this App");
882
+ table.put("/apps/:installationId/backend", appRoute(), async (context, { principal }) => {
1005
883
  const installation = await enabled(context);
1006
884
  if (installation === null)
1007
885
  return refusal(404, "Not found");
@@ -1030,10 +908,8 @@ export function createOyster(host) {
1030
908
  .fetch(admittedCallerFacts(installation, release, invocationCallerContext(principal, host.computerId ?? null), decision.grants), request);
1031
909
  return appFacetResponse(response);
1032
910
  });
1033
- app.put("/apps/:installationId/gills", async (context) => {
1034
- const caller = invocationCallerContext(context.get("principal"), host.computerId ?? null);
1035
- if (caller.actor.kind !== "member" || caller.role !== "owner")
1036
- return refusal(403, "Owner only");
911
+ table.put("/apps/:installationId/gills", owner("direct-or-app"), async (context, { principal }) => {
912
+ const caller = invocationCallerContext(principal, host.computerId ?? null);
1037
913
  const installation = await enabled(context);
1038
914
  if (installation === null)
1039
915
  return refusal(404, "Not found");
@@ -1046,7 +922,9 @@ export function createOyster(host) {
1046
922
  const request = decoded.value;
1047
923
  const release = await releaseOf(installation.release);
1048
924
  const gill = release.declaration?.gills?.find((item) => item.id === request.viewId);
1049
- if (gill === undefined || release.api === null || release.backend === null)
925
+ if (gill === undefined ||
926
+ release.api === null ||
927
+ release.backend === null)
1050
928
  return refusal(404, "Gill is not declared");
1051
929
  if (installation.instanceId === null)
1052
930
  return instanceRefusal("invalid_invocation_facts");
@@ -1079,7 +957,7 @@ export function createOyster(host) {
1079
957
  method?.effect !== "read" ||
1080
958
  !method.requires.every((capability) => release.declaration?.capabilities.includes(capability)))
1081
959
  return refusal(403, "Gill source is not admitted");
1082
- const current = await gateFacts(installation, release, context.get("principal"));
960
+ const current = await gateFacts(installation, release, principal);
1083
961
  if (current.declaredOperations.digest !==
1084
962
  installation.authority.declaredOperationsDigest ||
1085
963
  current.declaredOperations.artifactDigest !==
@@ -1170,20 +1048,16 @@ export function createOyster(host) {
1170
1048
  return instanceRefusal(result.code, result.causeClass);
1171
1049
  return methodResponse(result.value);
1172
1050
  };
1173
- app.post("/apps/:installationId/methods/:name", async (context) => {
1051
+ table.post("/apps/:installationId/methods/:name", appRoute(), async (context, { principal }) => {
1174
1052
  const installation = await enabled(context);
1175
1053
  if (installation === null)
1176
1054
  return refusal(404, "Not found");
1177
1055
  startTime(context, "method", "admission and Instance call");
1178
- const response = await executeMethod(installation, await releaseOf(installation.release), context.get("principal"), context.req.param("name"), context.req.raw);
1056
+ const response = await executeMethod(installation, await releaseOf(installation.release), principal, context.req.param("name"), context.req.raw);
1179
1057
  endTime(context, "method");
1180
1058
  return response;
1181
1059
  });
1182
- app.post("/apps/:caller/calls/:appId/:method", async (context) => {
1183
- const principal = context.get("principal");
1184
- if (oysterPrincipalIsVisitor(principal) ||
1185
- principal.actor.kind !== "member")
1186
- return refusal(403, "app_call_member_only");
1060
+ table.post("/apps/:caller/calls/:appId/:method", member(), async (context, { principal }) => {
1187
1061
  const caller = await host.installations.get(context.req.param("caller"));
1188
1062
  if (caller?.status !== "enabled")
1189
1063
  return refusal(404, "Not found");
@@ -1244,5 +1118,12 @@ export function createOyster(host) {
1244
1118
  };
1245
1119
  return executeMethod(target, release, principal, method, context.req.raw, recheck);
1246
1120
  });
1247
- return app;
1121
+ return {
1122
+ declarations: () => table.declarations(),
1123
+ serve: () => serveHost(table, authenticate, ports, {
1124
+ // Server-Timing names the Durable Object hops a request waited on.
1125
+ serverTiming: { totalDescription: "Oyster" },
1126
+ headers: [noIndex],
1127
+ }),
1128
+ };
1248
1129
  }