@fungi.computer/oyster 0.2.0 → 0.3.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 (287) hide show
  1. package/README.md +376 -72
  2. package/dist/agents.d.ts +3 -2
  3. package/dist/agents.js +4 -2
  4. package/dist/app-builds.d.ts +81 -8
  5. package/dist/app-builds.js +210 -60
  6. package/dist/app-storage.d.ts +3 -3
  7. package/dist/approval-capability.d.ts +5 -0
  8. package/dist/approval-capability.js +46 -0
  9. package/dist/approval-home.d.ts +51 -0
  10. package/dist/approval-home.js +226 -0
  11. package/dist/apps/catalog.d.ts +335 -0
  12. package/dist/apps/catalog.js +46 -0
  13. package/dist/apps/gate.d.ts +20 -6
  14. package/dist/apps/gate.js +42 -16
  15. package/dist/apps/index.d.ts +3 -1
  16. package/dist/apps/index.js +3 -1
  17. package/dist/apps/installation.d.ts +132 -51
  18. package/dist/apps/installation.js +176 -70
  19. package/dist/apps/permissions.d.ts +247 -0
  20. package/dist/apps/permissions.js +255 -0
  21. package/dist/boot-capability-route.d.ts +15 -0
  22. package/dist/boot-capability-route.js +196 -0
  23. package/dist/boot-capability.d.ts +103 -0
  24. package/dist/boot-capability.js +141 -0
  25. package/dist/boot-construction.d.ts +12 -0
  26. package/dist/boot-construction.js +175 -0
  27. package/dist/boot-gate.d.ts +267 -0
  28. package/dist/boot-gate.js +236 -0
  29. package/dist/boot-host-script.d.ts +2 -0
  30. package/dist/boot-host-script.js +1 -0
  31. package/dist/boot-mount.d.ts +21 -0
  32. package/dist/boot-mount.js +65 -0
  33. package/dist/browser/boot.d.ts +73 -0
  34. package/dist/browser/boot.js +502 -0
  35. package/dist/browser/browser.d.ts +104 -0
  36. package/dist/browser/browser.js +374 -0
  37. package/dist/browser/capability-source.d.ts +76 -0
  38. package/dist/browser/capability-source.js +338 -0
  39. package/dist/browser/cdp-client.d.ts +56 -0
  40. package/dist/browser/cdp-client.js +184 -0
  41. package/dist/browser/errors.d.ts +79 -0
  42. package/dist/browser/errors.js +32 -0
  43. package/dist/browser/index.d.ts +16 -0
  44. package/dist/browser/index.js +14 -0
  45. package/dist/browser/lease-registry.d.ts +61 -0
  46. package/dist/browser/lease-registry.js +101 -0
  47. package/dist/browser/operations.d.ts +225 -0
  48. package/dist/browser/operations.js +137 -0
  49. package/dist/browser/page.d.ts +25 -0
  50. package/dist/browser/page.js +172 -0
  51. package/dist/browser/quick-reads.d.ts +29 -0
  52. package/dist/browser/quick-reads.js +160 -0
  53. package/dist/browser/sessions.d.ts +52 -0
  54. package/dist/browser/sessions.js +118 -0
  55. package/dist/browser/url-policy.d.ts +18 -0
  56. package/dist/browser/url-policy.js +58 -0
  57. package/dist/build/app-imports.d.ts +12 -0
  58. package/dist/build/app-imports.js +105 -0
  59. package/dist/build/build-policy.d.ts +5 -3
  60. package/dist/build/build-policy.js +5 -3
  61. package/dist/build/candidate-builder.d.ts +1 -1
  62. package/dist/build/candidate-cli.mjs +8375 -334
  63. package/dist/build/local-build.d.ts +2 -0
  64. package/dist/build/local-build.js +28 -6
  65. package/dist/build/snapshot-package-store.d.ts +1 -1
  66. package/dist/build/snapshot-package-store.js +7 -2
  67. package/dist/build/workspace-build.d.ts +92 -5
  68. package/dist/build/workspace-build.js +93 -19
  69. package/dist/build.d.ts +1 -1
  70. package/dist/build.js +1 -1
  71. package/dist/catalog-frame.js +1 -1
  72. package/dist/cloudflare.d.ts +13 -3
  73. package/dist/cloudflare.js +102 -4
  74. package/dist/frame-binding-wire-script.js +1 -1
  75. package/dist/frame-binding-wire.js +4 -2
  76. package/dist/git-auth.d.ts +0 -2
  77. package/dist/git-auth.js +0 -2
  78. package/dist/git.d.ts +0 -1
  79. package/dist/git.js +0 -2
  80. package/dist/host.d.ts +39 -13
  81. package/dist/host.js +268 -142
  82. package/dist/index.d.ts +25 -15
  83. package/dist/index.js +39 -16
  84. package/dist/install-release-cli.d.ts +2 -0
  85. package/dist/install-release-cli.js +46 -0
  86. package/dist/installation-client.d.ts +43 -0
  87. package/dist/installation-client.js +178 -0
  88. package/dist/installation-execution.d.ts +11 -0
  89. package/dist/installation-execution.js +184 -0
  90. package/dist/installation-operations.d.ts +1437 -0
  91. package/dist/installation-operations.js +664 -0
  92. package/dist/installation-store-generation.d.ts +3 -0
  93. package/dist/installation-store-generation.js +16 -0
  94. package/dist/instance-id.d.ts +10 -0
  95. package/dist/instance-id.js +10 -0
  96. package/dist/instance-refusal.d.ts +11 -0
  97. package/dist/instance-refusal.js +21 -0
  98. package/dist/internal/delivery.js +4 -2
  99. package/dist/internal/host-gate.js +7 -1
  100. package/dist/internal/registry-installations.js +4 -4
  101. package/dist/keys.d.ts +5 -1
  102. package/dist/keys.js +10 -2
  103. package/dist/machine-link/authority.d.ts +89 -0
  104. package/dist/machine-link/authority.js +37 -0
  105. package/dist/machine-link/client.d.ts +66 -0
  106. package/dist/machine-link/client.js +94 -0
  107. package/dist/machine-link/delivery.d.ts +18 -0
  108. package/dist/machine-link/delivery.js +55 -0
  109. package/dist/machine-link/enrollment.d.ts +111 -0
  110. package/dist/machine-link/enrollment.js +286 -0
  111. package/dist/machine-link/fences.d.ts +37 -0
  112. package/dist/machine-link/fences.js +166 -0
  113. package/dist/machine-link/home.d.ts +20 -0
  114. package/dist/machine-link/home.js +24 -0
  115. package/dist/machine-link/host.d.ts +43 -0
  116. package/dist/machine-link/host.js +189 -0
  117. package/dist/machine-link/index.d.ts +15 -0
  118. package/dist/machine-link/index.js +15 -0
  119. package/dist/machine-link/link.d.ts +48 -0
  120. package/dist/machine-link/link.js +223 -0
  121. package/dist/machine-link/presence.d.ts +10 -0
  122. package/dist/machine-link/presence.js +25 -0
  123. package/dist/machine-link/workers.d.ts +6 -0
  124. package/dist/machine-link/workers.js +6 -0
  125. package/dist/mcp-owner.d.ts +2 -2
  126. package/dist/mcp-owner.js +41 -33
  127. package/dist/mcp.d.ts +15 -3
  128. package/dist/mcp.js +146 -25
  129. package/dist/pack.d.ts +14 -3
  130. package/dist/pack.js +44 -19
  131. package/dist/page/app-access.d.ts +7 -0
  132. package/dist/page/app-access.js +75 -0
  133. package/dist/page/boot-host.js +57 -0
  134. package/dist/page/capabilities.d.ts +3 -3
  135. package/dist/page/capabilities.js +16 -12
  136. package/dist/page/computer-handles.d.ts +3 -3
  137. package/dist/page/computer-target.d.ts +3 -0
  138. package/dist/page/computer-target.js +9 -0
  139. package/dist/page/mount.d.ts +23 -3
  140. package/dist/page/mount.js +152 -16
  141. package/dist/page-assets.d.ts +22 -0
  142. package/dist/page-assets.js +28 -0
  143. package/dist/page-script.d.ts +3 -0
  144. package/dist/page-script.js +5 -4
  145. package/dist/release.d.ts +13 -6
  146. package/dist/release.js +10 -7
  147. package/dist/root-data.d.ts +16 -6
  148. package/dist/root-data.js +2 -2
  149. package/dist/source/index.d.ts +1 -1
  150. package/dist/source/team-source.d.ts +20 -8
  151. package/dist/source/team-source.js +39 -10
  152. package/dist/source/template-seed.d.ts +2 -2
  153. package/dist/standalone/agent-mail.d.ts +24 -0
  154. package/dist/standalone/agent-mail.js +62 -0
  155. package/dist/standalone/agent-store.d.ts +9 -0
  156. package/dist/standalone/agent-store.js +30 -0
  157. package/dist/standalone/agents.d.ts +68 -9
  158. package/dist/standalone/agents.js +428 -56
  159. package/dist/standalone/app-builds-binding.d.ts +20 -2
  160. package/dist/standalone/app-builds-binding.js +147 -23
  161. package/dist/standalone/app-set.d.ts +5 -2
  162. package/dist/standalone/app-set.js +174 -143
  163. package/dist/standalone/browser.d.ts +11 -0
  164. package/dist/standalone/browser.js +72 -0
  165. package/dist/standalone/builds.d.ts +13 -4
  166. package/dist/standalone/builds.js +28 -44
  167. package/dist/standalone/computer-egress.d.ts +2 -0
  168. package/dist/standalone/computer-egress.js +13 -4
  169. package/dist/standalone/computer-fleet.d.ts +43 -0
  170. package/dist/standalone/computer-fleet.js +200 -0
  171. package/dist/standalone/computer.d.ts +44 -9
  172. package/dist/standalone/computer.js +129 -22
  173. package/dist/standalone/install-apps-cli.js +43 -6
  174. package/dist/standalone/instance.d.ts +2 -2
  175. package/dist/standalone/instance.js +8 -11
  176. package/dist/standalone/machine-link.d.ts +354 -0
  177. package/dist/standalone/machine-link.js +368 -0
  178. package/dist/standalone/media.d.ts +2 -2
  179. package/dist/standalone/media.js +19 -16
  180. package/dist/standalone/preview.d.ts +2 -2
  181. package/dist/standalone/preview.js +37 -10
  182. package/dist/standalone/registry.d.ts +149 -9
  183. package/dist/standalone/registry.js +299 -8
  184. package/dist/standalone/worker.d.ts +19 -5
  185. package/dist/standalone/worker.js +90 -54
  186. package/dist/standalone/workflow-binding.d.ts +18 -22
  187. package/dist/standalone/workflow-binding.js +18 -18
  188. package/dist/standalone/workflow-control.d.ts +4 -0
  189. package/dist/standalone/workflow-control.js +40 -0
  190. package/dist/standalone/workflow-events.d.ts +2 -2
  191. package/dist/standalone/workflow-events.js +9 -10
  192. package/dist/standalone/workflow-git-capture.js +4 -3
  193. package/dist/standalone/workflow-host.d.ts +162 -161
  194. package/dist/standalone/workflow-host.js +101 -21
  195. package/dist/standalone/workflow-node-capture.js +11 -4
  196. package/dist/standalone/workflow-schedules.d.ts +5 -2
  197. package/dist/standalone/workflow-schedules.js +9 -10
  198. package/dist/standalone/workflow-trigger-host.d.ts +65 -5
  199. package/dist/standalone/workflow-trigger-host.js +2 -2
  200. package/dist/standalone/workflow-webhooks.d.ts +22 -0
  201. package/dist/standalone/workflow-webhooks.js +39 -0
  202. package/dist/standalone/workflows.d.ts +140 -43
  203. package/dist/standalone/workflows.js +198 -39
  204. package/dist/storage-frame.d.ts +6 -6
  205. package/dist/testing/capabilities.js +14 -3
  206. package/dist/testing/installation.js +26 -2
  207. package/dist/testing/worker.js +1 -1
  208. package/dist/testing/workers-runtime.d.ts +8 -2
  209. package/dist/testing/workers-runtime.js +20 -2
  210. package/dist/webhook-abuse.d.ts +25 -0
  211. package/dist/webhook-abuse.js +54 -0
  212. package/dist/webhook-custody.d.ts +144 -0
  213. package/dist/webhook-custody.js +222 -0
  214. package/dist/webhook-ingress.d.ts +36 -0
  215. package/dist/webhook-ingress.js +146 -0
  216. package/dist/webhooks.d.ts +111 -0
  217. package/dist/webhooks.js +189 -0
  218. package/dist/workers-git.d.ts +12 -17
  219. package/dist/workers-git.js +60 -52
  220. package/dist/workers.d.ts +224 -13
  221. package/dist/workers.js +351 -51
  222. package/dist/workflow-agent-authority.d.ts +8 -0
  223. package/dist/workflow-agent-authority.js +42 -0
  224. package/dist/workflows.d.ts +66 -0
  225. package/dist/workflows.js +97 -0
  226. package/docs/api/README.md +63 -37
  227. package/docs/api/agents.md +17 -0
  228. package/docs/api/app-storage.md +1 -1
  229. package/docs/api/apps.md +1394 -543
  230. package/docs/api/browser/boot.md +132 -0
  231. package/docs/api/browser.md +1440 -0
  232. package/docs/api/build-contract.md +38 -25
  233. package/docs/api/build.md +263 -84
  234. package/docs/api/cloudflare.md +93 -7
  235. package/docs/api/git.md +3 -13
  236. package/docs/api/host.md +944 -118
  237. package/docs/api/index.md +320 -18
  238. package/docs/api/keys.md +24 -1
  239. package/docs/api/mcp.md +59 -12
  240. package/docs/api/modules.md +33 -40
  241. package/docs/api/pack.md +57 -8
  242. package/docs/api/page/mount.md +92 -4
  243. package/docs/api/workers-git.md +103 -24
  244. package/docs/api/workers.md +1581 -244
  245. package/docs/site/agent-quickstart.mdx +28 -14
  246. package/examples/standalone/worker.ts +3 -1
  247. package/examples/standalone/wrangler.jsonc +2 -0
  248. package/package.json +73 -71
  249. package/dist/installation-consent.d.ts +0 -81
  250. package/dist/installation-consent.js +0 -84
  251. package/dist/shell/app-directory.d.ts +0 -542
  252. package/dist/shell/app-directory.js +0 -196
  253. package/dist/shell/browser-actions.d.ts +0 -7
  254. package/dist/shell/browser-actions.js +0 -56
  255. package/dist/shell/command-host.d.ts +0 -112
  256. package/dist/shell/command-host.js +0 -502
  257. package/dist/shell/commands.d.ts +0 -25
  258. package/dist/shell/commands.js +0 -78
  259. package/dist/shell/index.d.ts +0 -8
  260. package/dist/shell/index.js +0 -8
  261. package/dist/shell/keys.d.ts +0 -41
  262. package/dist/shell/keys.js +0 -134
  263. package/dist/shell/shell-module.d.ts +0 -55
  264. package/dist/shell/shell-module.js +0 -86
  265. package/dist/shell/window-manager.d.ts +0 -128
  266. package/dist/shell/window-manager.js +0 -730
  267. package/dist/shell/workbench-contract.d.ts +0 -68
  268. package/dist/shell/workbench-directory.d.ts +0 -332
  269. package/dist/shell/workbench-directory.js +0 -35
  270. package/dist/shell/workbench-host-types.d.ts +0 -96
  271. package/dist/shell/workbench-host-types.js +0 -1
  272. package/dist/shell/workbench-host.d.ts +0 -34
  273. package/dist/shell/workbench-host.js +0 -352
  274. package/dist/shell/workbench-model.d.ts +0 -194
  275. package/dist/shell/workbench-model.js +0 -53
  276. package/dist/shell/workbench.d.ts +0 -4
  277. package/dist/shell/workbench.js +0 -2
  278. package/docs/api/shell/app-directory.md +0 -599
  279. package/docs/api/shell/command-host.md +0 -224
  280. package/docs/api/shell/commands.md +0 -197
  281. package/docs/api/shell/keys.md +0 -183
  282. package/docs/api/shell/shell-module.md +0 -204
  283. package/docs/api/shell/window-manager.md +0 -267
  284. package/docs/api/shell/workbench-directory.md +0 -119
  285. package/docs/api/shell/workbench.md +0 -205
  286. package/docs/api/shell.md +0 -1915
  287. /package/dist/{shell/workbench-contract.js → page/boot-host.d.ts} +0 -0
package/dist/host.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { hostGateFacts, releaseCache } from "./internal/host-gate.js";
2
+ import { InstallationOperationError, installationCommandIds, installationRequestIdSchema, } from "./installation-operations.js";
3
+ import { completedInstallationResponse, observeInstallation, InstallationObservationError, } from "./installation-client.js";
4
+ import { instanceRefusalBody, instanceRefusalStatus, } from "./instance-refusal.js";
2
5
  import { appSandbox } from "./app-sandbox.js";
3
- import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
4
6
  import { shellModuleAsset, shellModuleAssetPath, } from "@fungi.computer/puffball/assets";
5
- import { installationConsentSchema, capabilityDescriptions, } from "./installation-consent.js";
6
7
  import { gitRepositoryNameSchema } from "@fungi.computer/puffball/git-representation";
7
8
  import { gillRequestSchema, decodeGillReply, decodeDeclaredGillResponse, } from "@fungi.computer/gills";
8
9
  import { causeClass } from "./cause-class.js";
9
10
  import { appStorageKeyNameSchema, appStorageWriteSchema, appStorageResetSchema, } from "@fungi.computer/agar/storage";
10
- import {} from "@fungi.computer/agar/git";
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";
@@ -16,19 +16,21 @@ import { bodyLimit } from "hono/body-limit";
16
16
  import { match } from "path-to-regexp";
17
17
  import * as Schema from "effect/Schema";
18
18
  import * as Result from "effect/Result";
19
- import {} from "@fungi.computer/puffball/background-replay";
20
19
  import { emptyAppDeclaration } from "@fungi.computer/puffball/app";
21
- import { applyInstallationCommand, installationIdSchema, installationCommandSchema, } from "./apps/installation.js";
20
+ import { applyInstallationCommand, installationIdSchema, } from "./apps/installation.js";
22
21
  import { admits, scopedBindings, grantsFor } from "./apps/gate.js";
22
+ import { appPermissionPageSchema, permissionSaveSchema, } from "./apps/permissions.js";
23
23
  import { deriveDeclaredOperations, appCallOperation, } from "./apps/operations.js";
24
24
  import { appBackendActorHeader, appBackendComputerHeader, appMethodRefusalStatus, } from "@fungi.computer/agar/backend";
25
25
  import { appCallsDescriptor, } from "./capability-descriptor.js";
26
26
  export { capabilityDescriptorSchema, capabilityMethodKindSchema, capabilityMethodSchema, } from "./capability-descriptor.js";
27
- import { instanceIds, selectionOf, } from "./index.js";
28
- import { parseRelease, readVerified, ReleaseError, sha256, verifyRelease, } from "./release.js";
27
+ import { selectionOf, } from "./index.js";
28
+ import { parseRelease, readVerified, sha256, } from "./release.js";
29
29
  import { createGitDoor } from "./git.js";
30
- import { buildGitPassPrefix, presentedGitKey, workflowGitPassPrefix, } from "./git-auth.js";
31
- import { pageScript, publicHomeScript, grantConsentScript, fontStyles, shellStyles, } from "#page-script";
30
+ import { buildGitPassPrefix, presentedGitKey } from "./git-auth.js";
31
+ import { workflowGitPassPrefix } from "@fungi.computer/braid/runtime";
32
+ import { loadPageAssets, PageAssetsLoadError, } from "./page-assets.js";
33
+ import { fungiFaviconHref } from "@fungi.computer/sigils/favicon";
32
34
  import { createElement } from "react";
33
35
  import { renderToStaticMarkup } from "react-dom/server";
34
36
  import { CenteredCard, CardHeader, CardTitle, CardContent, } from "@fungi.computer/caps/components/card";
@@ -92,14 +94,16 @@ function refusal(status, error, retryAfter) {
92
94
  init.headers = { "Retry-After": String(retryAfter) };
93
95
  return Response.json({ error }, init);
94
96
  }
97
+ function installationOperationRefusal(cause) {
98
+ return cause.code === "stored_operation_invalid"
99
+ ? Response.json({ error: cause.code, message: cause.message }, { status: 409 })
100
+ : refusal(cause.code === "unavailable" ? 503 : 409, cause.code);
101
+ }
102
+ /** A refusal no cache may keep: its answer depends on current authority. */
103
+ function noStoreRefusal(status, error) {
104
+ return Response.json({ error }, { status, headers: { "Cache-Control": "no-store" } });
105
+ }
95
106
  function instanceRefusal(code, causeClass) {
96
- const conflict = [
97
- "conflicting_owner_state",
98
- "stale_authority",
99
- "instance_release_mismatch",
100
- "release_no_longer_matches_instance",
101
- "state_schema_migration_unsupported",
102
- ];
103
107
  if (code === "failed") {
104
108
  if (causeClass === undefined)
105
109
  console.error("AppInstance failure", {
@@ -114,7 +118,9 @@ function instanceRefusal(code, causeClass) {
114
118
  });
115
119
  return refusal(502, "App execution failed");
116
120
  }
117
- return refusal(conflict.includes(code) ? 409 : 403, code);
121
+ return Response.json(instanceRefusalBody(code), {
122
+ status: instanceRefusalStatus(code),
123
+ });
118
124
  }
119
125
  /** An App's typed refusal keeps its code and caller-facing message. */
120
126
  function methodResponse(reply) {
@@ -123,69 +129,69 @@ function methodResponse(reply) {
123
129
  : Response.json({ error: reply.code, message: reply.message }, { status: appMethodRefusalStatus[reply.code] });
124
130
  }
125
131
  /**
126
- * Install a Release at `installationId`, or switch an already-live one onto
127
- * it: the one path the POST install and PUT release-select routes share,
128
- * and that a build owner's own install call reaches too. Nothing there (or
129
- * only a deleted installation) is a fresh install; a live installation is
130
- * switched in place, refusing a change of the installation's App identity.
132
+ * Admit the retained command shared by POST and PUT selection. Only POST's
133
+ * explicit install-or-remint intent
134
+ * may choose a fresh id for a tombstone; ordinary selection keeps the retired
135
+ * id unavailable. App-mediated build selection stays refused until it has
136
+ * designated install authority.
131
137
  */
132
- export async function installOrSelect(host, installationId, release, principal) {
133
- let verifiedRelease;
138
+ async function admitInstallation(host, installationId, release, principal, intent, origin, requestId = installationCommandIds.request.generate()) {
139
+ // App-mediated installation needs installFree/designated-marketplace
140
+ // authority. A member's owner role is not that delegated App authority.
141
+ if (origin !== "owner-direct" && origin !== "workflow")
142
+ return refusal(403, "install_authority_unavailable");
143
+ if (!oysterPrincipalIsOwner(principal))
144
+ return refusal(403, "Owner only");
145
+ if (!installationIdSchema.safeParse(installationId).success)
146
+ return refusal(400, "Invalid installation id");
147
+ if (!installationRequestIdSchema.safeParse(requestId).success)
148
+ return refusal(400, "Invalid installation request identity");
149
+ const operations = host.installations.operations;
150
+ if (operations === undefined)
151
+ return refusal(503, "Installation commands unavailable");
134
152
  try {
135
- verifiedRelease = await verifyRelease(host.artifacts, release);
153
+ return await operations.admit({
154
+ requestId,
155
+ installationId,
156
+ release,
157
+ actor: principal.actor,
158
+ intent,
159
+ origin,
160
+ });
136
161
  }
137
162
  catch (cause) {
138
- return cause instanceof ReleaseError
139
- ? refusal(422, cause.code)
140
- : refusal(502, "Release could not be read");
163
+ if (cause instanceof InstallationOperationError)
164
+ return installationOperationRefusal(cause);
165
+ throw cause;
141
166
  }
142
- const operationDescriptors = host.capabilities ?? [];
143
- const reduce = async (current, command, identity) => {
144
- const context = invocationCallerContext(principal, host.computerId ?? null);
145
- const declaration = verifiedRelease.declaration ?? emptyAppDeclaration;
146
- return applyInstallationCommand(current, command, {
147
- by: context.actor,
148
- role: context.role ?? null,
149
- expectedVersion: current?.authorityVersion ?? null,
150
- declaration,
151
- declaredOperations: await deriveDeclaredOperations(verifiedRelease.release, declaration, operationDescriptors),
152
- now: Date.now(),
153
- identity,
154
- });
155
- };
156
- const apply = async (installation) => {
157
- if (verifiedRelease.backend === null)
158
- return null;
159
- const limits = guestLimits(host.guestLimits);
160
- const result = await host.instances
161
- .get(selectionOf(installation).instanceId, limits)
162
- .apply({ ...selectionOf(installation), status: installation.status }, installation.release);
163
- return result.ok ? null : instanceRefusal(result.code, result.causeClass);
164
- };
165
- const current = await host.installations.get(installationId);
166
- if (current !== null && current.status !== "deleted") {
167
- const update = await reduce(current, { kind: "select-release", release, releasePin: "pinned" }, current);
168
- if (Result.isFailure(update))
169
- return refusal(409, update.failure.code);
170
- const next = update.success.next;
171
- const refused = await apply(next);
172
- if (refused !== null)
173
- return refused;
174
- await host.installations.put(next);
175
- return Response.json(next);
167
+ }
168
+ /** Completed selection, without re-deriving or executing outside Watchdog. */
169
+ export async function installOrSelect(host, installationId, release, principal, intent, origin, requestId = installationCommandIds.request.generate(), signal = new AbortController().signal) {
170
+ const admitted = await admitInstallation(host, installationId, release, principal, intent, origin, requestId);
171
+ if (admitted instanceof Response)
172
+ return admitted;
173
+ const operations = host.installations.operations;
174
+ if (operations === undefined)
175
+ return refusal(503, "Installation commands unavailable");
176
+ try {
177
+ return completedInstallationResponse(await observeInstallation({
178
+ requestId,
179
+ initial: admitted,
180
+ read: () => operations.read(requestId, installationId),
181
+ signal,
182
+ }));
183
+ }
184
+ catch (cause) {
185
+ if (cause instanceof InstallationObservationError &&
186
+ cause.cause instanceof InstallationOperationError)
187
+ return installationOperationRefusal(cause.cause);
188
+ if (cause instanceof InstallationObservationError)
189
+ return Response.json({ error: "Installation outcome unconfirmed", requestId }, { status: 503 });
190
+ throw cause;
176
191
  }
177
- if (!installationIdSchema.safeParse(installationId).success)
178
- return refusal(400, "Invalid installation id");
179
- const update = await reduce(null, { kind: "install", release, releasePin: "pinned", grants: "declared" }, { installationId, instanceId: instanceIds.instance.generate() });
180
- if (Result.isFailure(update))
181
- return refusal(409, update.failure.code);
182
- const installation = update.success.next;
183
- const refused = await apply(installation);
184
- if (refused !== null)
185
- return refused;
186
- await host.installations.put(installation);
187
- return Response.json(installation, { status: 201 });
188
192
  }
193
+ /** The tab icon of every Oyster page a person opens at the top level. */
194
+ const tabIcon = `<link rel="icon" type="image/svg+xml" href="${fungiFaviconHref}">`;
189
195
  /**
190
196
  * Create the Oyster App server: a Hono app that installs App Releases, serves
191
197
  * their UI, forwards their backend requests and methods to their Instances,
@@ -198,13 +204,22 @@ export function createOyster(host) {
198
204
  const releaseOf = releaseCache(host.artifacts);
199
205
  const operationDescriptors = host.capabilities ?? [];
200
206
  const gateFacts = (installation, release, principal) => hostGateFacts(host, installation, release, principal);
207
+ // Current approval for the home shell's code, read on every entry request.
208
+ const shellApproved = async (installation, release) => {
209
+ const declaration = release.declaration ?? emptyAppDeclaration;
210
+ return (grantsFor({
211
+ status: installation.status,
212
+ authority: installation.authority,
213
+ artifactDigest: installation.release.artifact.artifactDigest,
214
+ }, declaration, await deriveDeclaredOperations(installation.release, declaration, operationDescriptors), "team.workbenchHost") !== null);
215
+ };
201
216
  const reduceInstallation = async (current, command, release, principal, identity) => {
202
217
  const context = invocationCallerContext(principal, host.computerId ?? null);
203
218
  const declaration = release.declaration ?? emptyAppDeclaration;
204
219
  return applyInstallationCommand(current, command, {
205
220
  by: context.actor,
206
221
  role: context.role ?? null,
207
- expectedVersion: current?.authorityVersion ?? null,
222
+ expectedVersion: current?.authority.authorityVersion ?? null,
208
223
  declaration,
209
224
  declaredOperations: await deriveDeclaredOperations(release.release, declaration, operationDescriptors),
210
225
  now: Date.now(),
@@ -217,19 +232,55 @@ export function createOyster(host) {
217
232
  contentType,
218
233
  source,
219
234
  });
220
- const assets = Promise.all([
221
- asset("page.js", "text/javascript; charset=utf-8", host.pageScript ?? pageScript),
222
- asset("public-home.js", "text/javascript; charset=utf-8", publicHomeScript),
223
- asset("grant-consent.js", "text/javascript; charset=utf-8", grantConsentScript),
224
- asset("fonts.css", "text/css; charset=utf-8", fontStyles),
225
- asset("styles.css", "text/css; charset=utf-8", shellStyles),
226
- ]).then(([page, publicHome, grantConsent, fonts, styles]) => ({
227
- page,
228
- publicHome,
229
- grantConsent,
230
- fonts,
231
- styles,
235
+ const prepareAssets = async () => {
236
+ const { pageScript, publicHomeScript, grantConsentScript, fontStyles, loginFontStyles, shellStyles, } = await (host.pageAssets ?? loadPageAssets)();
237
+ return Promise.all([
238
+ asset("page.js", "text/javascript; charset=utf-8", host.pageScript ?? pageScript),
239
+ asset("public-home.js", "text/javascript; charset=utf-8", publicHomeScript),
240
+ asset("grant-consent.js", "text/javascript; charset=utf-8", grantConsentScript),
241
+ asset("fonts.css", "text/css; charset=utf-8", fontStyles),
242
+ asset("login-fonts.css", "text/css; charset=utf-8", loginFontStyles),
243
+ asset("styles.css", "text/css; charset=utf-8", shellStyles),
244
+ ]).then(([page, publicHome, grantConsent, fonts, loginFonts, styles]) => ({
245
+ page,
246
+ publicHome,
247
+ grantConsent,
248
+ fonts,
249
+ loginFonts,
250
+ styles,
251
+ }));
252
+ };
253
+ let preparedAssets;
254
+ const assets = () => (preparedAssets ??= prepareAssets().catch((cause) => {
255
+ preparedAssets = undefined;
256
+ throw cause instanceof PageAssetsLoadError
257
+ ? cause
258
+ : new PageAssetsLoadError(cause);
232
259
  }));
260
+ // The home page imports this host-owned entry, never the Release module's
261
+ // own URL: released Oyster 0.2.0 served that URL immutable, so a browser may
262
+ // hold a copy it would import without a request. The entry is never cached
263
+ // and rechecks current approval before it names the module.
264
+ app.get(`/ui-entry/:installationId/:releaseId${shellModuleAssetPath}`, async (context) => {
265
+ const installation = await host.installations.get(context.req.param("installationId"));
266
+ if (installation?.status !== "enabled" ||
267
+ installation.release.releaseId !== context.req.param("releaseId"))
268
+ return noStoreRefusal(404, "Not found");
269
+ const release = await releaseOf(installation.release);
270
+ if (release.ui?.kind !== "assets" ||
271
+ shellModuleAsset(release.ui.build) === undefined)
272
+ return noStoreRefusal(404, "Not found");
273
+ if (!(await shellApproved(installation, release)))
274
+ return noStoreRefusal(403, "not_approved");
275
+ const module = `/ui/${installation.installationId}/${installation.release.releaseId}${shellModuleAssetPath}`;
276
+ return new Response(`export * from ${JSON.stringify(module)};\n`, {
277
+ headers: {
278
+ "Content-Type": "text/javascript; charset=utf-8",
279
+ "X-Content-Type-Options": "nosniff",
280
+ "Cache-Control": "no-store",
281
+ },
282
+ });
283
+ });
233
284
  // App UI: digest-pinned, sandboxed, and readable without a session because
234
285
  // the opaque-origin frame cannot carry one.
235
286
  app.get("/ui/:installationId/:releaseId/*", async (context) => {
@@ -260,6 +311,11 @@ export function createOyster(host) {
260
311
  const asset = release.ui.build.assets.find((item) => item.path === wanted);
261
312
  if (asset === undefined)
262
313
  return refusal(404, "Not found");
314
+ // The Release's shell module is read only through the host's entry, but
315
+ // keeps the same per-request check; other UI assets stay digest-cacheable.
316
+ const shellEntry = wanted === shellModuleAssetPath;
317
+ if (shellEntry && !(await shellApproved(installation, release)))
318
+ return noStoreRefusal(403, "not_approved");
263
319
  const bytes = await readVerified(host.artifacts, asset.artifactRef, asset.digest);
264
320
  const scope = `${origin}${prefix}/`;
265
321
  return new Response(new Uint8Array(bytes), {
@@ -268,13 +324,15 @@ export function createOyster(host) {
268
324
  "Content-Security-Policy": `default-src 'none'; script-src ${scope} 'wasm-unsafe-eval'; style-src ${scope} 'unsafe-inline'; img-src ${scope} blob: data:; media-src blob:; font-src ${scope} data:; connect-src data: ${scope}; worker-src blob:; frame-src 'none'; frame-ancestors ${origin}; base-uri 'none'; form-action 'none'; sandbox ${appSandbox}`,
269
325
  "Access-Control-Allow-Origin": "*",
270
326
  "X-Content-Type-Options": "nosniff",
271
- "Cache-Control": "public, max-age=31536000, immutable",
327
+ "Cache-Control": shellEntry
328
+ ? "no-store"
329
+ : "public, max-age=31536000, immutable",
272
330
  },
273
331
  });
274
332
  });
275
333
  app.get("/_oyster/assets/*", async (context) => {
276
334
  const wanted = new URL(context.req.url).pathname;
277
- const found = Object.values(await assets).find(({ path }) => path === wanted);
335
+ const found = Object.values(await assets()).find(({ path }) => path === wanted);
278
336
  if (found === undefined)
279
337
  return refusal(404, "Not found");
280
338
  return new Response(found.source, {
@@ -286,7 +344,7 @@ export function createOyster(host) {
286
344
  });
287
345
  });
288
346
  app.get("/login", async () => {
289
- const { fonts, styles } = await assets;
347
+ const { loginFonts, styles } = await assets();
290
348
  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, {
291
349
  label: "Owner key",
292
350
  required: true,
@@ -297,7 +355,7 @@ export function createOyster(host) {
297
355
  required: true,
298
356
  }),
299
357
  }), createElement(FormFooter, { layout: "stretch", divider: false }, createElement(Button, { type: "submit" }, "Sign in"))))));
300
- return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Sign in · ${escapeHtml(title)}</title><link rel="stylesheet" href="${styles.path}"><link rel="stylesheet" href="${fonts.path}"></head><body class="px-lg">${form}</body></html>`, {
358
+ return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Sign in · ${escapeHtml(title)}</title>${tabIcon}<link rel="stylesheet" href="${styles.path}"><link rel="stylesheet" href="${loginFonts.path}"></head><body class="px-lg">${form}</body></html>`, {
301
359
  headers: {
302
360
  "Content-Type": "text/html; charset=utf-8",
303
361
  "Cache-Control": "no-store",
@@ -461,7 +519,7 @@ export function createOyster(host) {
461
519
  });
462
520
  }
463
521
  const config = JSON.stringify({ title, apps }).replaceAll("<", "\\u003c");
464
- return new Response(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(title)}</title><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>`, {
522
+ 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>`, {
465
523
  headers: {
466
524
  "Content-Type": "text/html; charset=utf-8",
467
525
  "Cache-Control": "no-store",
@@ -473,7 +531,7 @@ export function createOyster(host) {
473
531
  const items = installations
474
532
  .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>`)
475
533
  .join("");
476
- return new Response(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(title)}</title><h1>${escapeHtml(title)}</h1><ul>${items || "<li>No Apps installed yet.</li>"}</ul>`, {
534
+ 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>`, {
477
535
  headers: {
478
536
  "Content-Type": "text/html; charset=utf-8",
479
537
  "Cache-Control": "no-store",
@@ -488,6 +546,7 @@ export function createOyster(host) {
488
546
  return undefined;
489
547
  });
490
548
  app.put("/api/installations/:installationId/visibility", async (context) => {
549
+ const generation = await host.installations.generation();
491
550
  const current = await host.installations.get(context.req.param("installationId"));
492
551
  if (current === null || current.status === "deleted")
493
552
  return refusal(404, "Not found");
@@ -498,10 +557,16 @@ export function createOyster(host) {
498
557
  if (Result.isFailure(update))
499
558
  return refusal(409, update.failure.code);
500
559
  const next = update.success.next;
501
- const refused = await apply(next, await releaseOf(current.release));
560
+ const release = await releaseOf(current.release);
561
+ const refused = await prepare(next, release);
502
562
  if (refused !== null)
503
563
  return refused;
504
- await host.installations.put(next);
564
+ const commit = await host.installations.compareAndPut(next, current.authority.authorityVersion, generation);
565
+ if (!commit.ok)
566
+ return refusal(409, commit.code);
567
+ const activationRefusal = await apply(next, release);
568
+ if (activationRefusal !== null)
569
+ return activationRefusal;
505
570
  return Response.json(next);
506
571
  });
507
572
  if (host.ownerRoutes !== undefined) {
@@ -584,6 +649,14 @@ export function createOyster(host) {
584
649
  await host.artifacts.put(artifactRef, bytes);
585
650
  return new Response(null, { status: 204 });
586
651
  });
652
+ const prepare = async (installation, verifiedRelease) => {
653
+ if (verifiedRelease.backend === null)
654
+ return null;
655
+ const result = await host.instances
656
+ .get(selectionOf(installation).instanceId, limits)
657
+ .prepare({ ...selectionOf(installation), status: installation.status }, installation.release);
658
+ return result.ok ? null : instanceRefusal(result.code, result.causeClass);
659
+ };
587
660
  const apply = async (installation, verifiedRelease) => {
588
661
  if (verifiedRelease.backend === null)
589
662
  return null;
@@ -603,44 +676,67 @@ export function createOyster(host) {
603
676
  return refusal(400, "Invalid install request");
604
677
  }
605
678
  const installationId = request.installationId ?? release.appId.replaceAll("_", "-");
606
- return installOrSelect(host, installationId, release, context.get("principal"));
679
+ if (!installationIdSchema.safeParse(installationId).success)
680
+ return refusal(400, "Invalid installation id");
681
+ const key = installationRequestIdSchema.safeParse(context.req.header("Idempotency-Key"));
682
+ if (!key.success)
683
+ return refusal(400, "Invalid installation request identity");
684
+ const admitted = await admitInstallation(host, installationId, release, context.get("principal"), "install-or-remint", context.req.raw.headers.has("X-Oyster-Installation")
685
+ ? "app"
686
+ : "owner-direct", key.data);
687
+ return admitted instanceof Response
688
+ ? admitted
689
+ : Response.json(admitted, { status: 202 });
690
+ });
691
+ app.get("/api/installations/:installationId/status", async (context) => {
692
+ if (!oysterPrincipalIsOwner(context.get("principal")) ||
693
+ context.req.raw.headers.has("X-Oyster-Installation"))
694
+ return refusal(403, "Owner only");
695
+ const key = installationRequestIdSchema.safeParse(context.req.query("requestId"));
696
+ if (!key.success)
697
+ return refusal(400, "Invalid installation request identity");
698
+ const operations = host.installations.operations;
699
+ if (operations === undefined)
700
+ return refusal(503, "Installation commands unavailable");
701
+ try {
702
+ const operation = await operations.read(key.data, context.req.param("installationId"));
703
+ return operation === null
704
+ ? refusal(404, "Installation request not found")
705
+ : Response.json(operation);
706
+ }
707
+ catch (cause) {
708
+ if (cause instanceof InstallationOperationError)
709
+ return installationOperationRefusal(cause);
710
+ throw cause;
711
+ }
607
712
  });
608
713
  app.get("/api/installations/:installationId/grants", async (context) => {
609
714
  if (context.req.raw.headers.has("X-Oyster-Installation"))
610
715
  return refusal(403, "Owner direct only");
716
+ const generation = await host.installations.generation();
611
717
  const current = await host.installations.get(context.req.param("installationId"));
612
718
  if (current === null || current.status === "deleted")
613
719
  return refusal(404, "Not found");
614
720
  const verified = await releaseOf(current.release);
615
721
  const declaration = verified.declaration ?? emptyAppDeclaration;
616
722
  const declared = await deriveDeclaredOperations(current.release, declaration, operationDescriptors);
617
- const config = Schema.decodeUnknownSync(installationConsentSchema)({
723
+ const config = appPermissionPageSchema.parse({
724
+ storeGeneration: generation,
618
725
  installationId: current.installationId,
619
726
  appId: current.appId,
620
727
  appName: declaration.displayName ?? current.installationId,
621
- authorityVersion: current.authorityVersion,
622
728
  artifactDigest: current.release.artifact.artifactDigest,
623
- capabilities: declaration.capabilities.map((capability) => {
624
- const existing = current.approvedGrants.find((grant) => grant.capability === capability);
625
- return {
626
- approved: existing !== undefined,
627
- description: `Lets this App ${capabilityDescriptions[capability]}`,
628
- scope: (existing?.resources ?? "tenant-scope") === "tenant-scope"
629
- ? "All of this Oyster"
630
- : "Only the resources you already approved",
631
- request: {
632
- capability,
633
- operations: existing?.operations ??
634
- declared.capabilities.find((row) => row.capability === capability)
635
- ?.operations ??
636
- [],
637
- resources: existing?.resources ?? "tenant-scope",
638
- },
639
- };
640
- }),
729
+ permissions: {
730
+ authority: current.authority,
731
+ declaredOperations: declared,
732
+ },
641
733
  });
734
+ if (context.req.header("Accept")?.includes("application/json"))
735
+ return Response.json(config, {
736
+ headers: { "Cache-Control": "no-store" },
737
+ });
642
738
  const encoded = JSON.stringify(config).replaceAll("<", "\\u003c");
643
- return new Response(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(config.appName)} permissions</title><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)}</style><div id="oyster-consent"></div><script type="application/json" id="oyster-consent-config">${encoded}</script><script type="module" src="${(await assets).grantConsent.path}"></script>`, {
739
+ return new Response(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(config.appName)} permissions</title><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)}</style><div id="oyster-consent"></div><script type="application/json" id="oyster-consent-config">${encoded}</script><script type="module" src="${(await assets()).grantConsent.path}"></script>`, {
644
740
  headers: {
645
741
  "Content-Type": "text/html; charset=utf-8",
646
742
  "Cache-Control": "no-store",
@@ -659,27 +755,33 @@ export function createOyster(host) {
659
755
  const principal = context.get("principal");
660
756
  if (!oysterPrincipalIsOwner(principal))
661
757
  return refusal(403, "Owner only");
662
- const parsed = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Struct({
663
- authorityVersion: Schema.Int.check(Schema.isGreaterThan(0)),
664
- artifactDigest: sha256DigestSchema,
665
- command: Schema.Unknown,
666
- })), { onExcessProperty: "error" })(await context.req.text());
667
- if (parsed._tag === "None")
758
+ const body = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))(await context.req.text());
759
+ if (body._tag === "None")
668
760
  return refusal(400, "Invalid approval");
669
- const command = installationCommandSchema.safeParse(parsed.value.command);
670
- if (!command.success || command.data.kind !== "set-grants")
761
+ const parsed = permissionSaveSchema.safeParse(body.value);
762
+ if (!parsed.success || parsed.data.command.kind !== "change-grants")
671
763
  return refusal(400, "Invalid approval");
764
+ const generation = await host.installations.generation();
765
+ if (generation !== parsed.data.storeGeneration)
766
+ return refusal(409, "stale_authority");
672
767
  const current = await host.installations.get(context.req.param("installationId"));
673
- if (current === null || current.status === "deleted")
768
+ if (current === null)
674
769
  return refusal(404, "Not found");
675
- if (current.authorityVersion !== parsed.value.authorityVersion ||
676
- current.release.artifact.artifactDigest !== parsed.value.artifactDigest)
770
+ // A retired installation's pins can never match a live record: the
771
+ // page already offers a reload, which then finds the App gone.
772
+ if (current.status === "deleted")
773
+ return refusal(409, "stale_authority");
774
+ if (current.authority.authorityVersion !== parsed.data.authorityVersion ||
775
+ current.release.artifact.artifactDigest !== parsed.data.artifactDigest)
677
776
  return refusal(409, "stale_authority");
678
777
  const release = await releaseOf(current.release);
679
- const update = await reduceInstallation(current, command.data, release, principal, current);
778
+ const update = await reduceInstallation(current, parsed.data.command, release, principal, current);
680
779
  if (Result.isFailure(update))
681
780
  return refusal(409, update.failure.code);
682
- const commit = await host.installations.compareAndPut(update.success.next, parsed.value.authorityVersion);
781
+ const preflightRefusal = await prepare(update.success.next, release);
782
+ if (preflightRefusal !== null)
783
+ return preflightRefusal;
784
+ const commit = await host.installations.compareAndPut(update.success.next, parsed.data.authorityVersion, parsed.data.storeGeneration);
683
785
  if (!commit.ok)
684
786
  return refusal(409, commit.code);
685
787
  const refused = await apply(update.success.next, release);
@@ -695,9 +797,18 @@ export function createOyster(host) {
695
797
  catch {
696
798
  return refusal(400, "Invalid Release");
697
799
  }
698
- return installOrSelect(host, context.req.param("installationId"), release, context.get("principal"));
800
+ const key = installationRequestIdSchema.safeParse(context.req.header("Idempotency-Key") ??
801
+ installationCommandIds.request.generate());
802
+ if (!key.success)
803
+ return refusal(400, "Invalid installation request identity");
804
+ // PUT retains its completed-response meaning. Cancellation only stops this
805
+ // observer; Watchdog's retained command continues independently.
806
+ return installOrSelect(host, context.req.param("installationId"), release, context.get("principal"), "install-or-select", context.req.raw.headers.has("X-Oyster-Installation")
807
+ ? "app"
808
+ : "owner-direct", key.data, context.req.raw.signal);
699
809
  });
700
810
  app.delete("/api/installations/:installationId", async (context) => {
811
+ const generation = await host.installations.generation();
701
812
  const current = await host.installations.get(context.req.param("installationId"));
702
813
  if (current === null || current.status === "deleted")
703
814
  return refusal(404, "Not found");
@@ -705,10 +816,16 @@ export function createOyster(host) {
705
816
  if (Result.isFailure(update))
706
817
  return refusal(409, update.failure.code);
707
818
  const next = update.success.next;
708
- const refused = await apply(next, await releaseOf(current.release));
819
+ const release = await releaseOf(current.release);
820
+ const refused = await prepare(next, release);
709
821
  if (refused !== null)
710
822
  return refused;
711
- await host.installations.put(next);
823
+ const commit = await host.installations.compareAndPut(next, current.authority.authorityVersion, generation);
824
+ if (!commit.ok)
825
+ return refusal(409, commit.code);
826
+ const activationRefusal = await apply(next, release);
827
+ if (activationRefusal !== null)
828
+ return activationRefusal;
712
829
  return new Response(null, { status: 204 });
713
830
  });
714
831
  const enabled = async (context) => {
@@ -754,7 +871,8 @@ export function createOyster(host) {
754
871
  const workbench = oysterPrincipalIsOwner(principal) &&
755
872
  host.workbench !== undefined &&
756
873
  installation.installationId === host.homeInstallationId &&
757
- declaration?.capabilities.includes("team.workbenchHost") === true;
874
+ declaration?.capabilities.includes("team.workbenchHost") === true &&
875
+ grantsFor(facts.installation, facts.declaration, facts.declaredOperations, "team.workbenchHost") !== null;
758
876
  const uiUrl = `/ui/${installation.installationId}/${installation.release.releaseId}/`;
759
877
  const config = {
760
878
  workbench,
@@ -763,7 +881,7 @@ export function createOyster(host) {
763
881
  shellModuleUrl: workbench &&
764
882
  release.ui?.kind === "assets" &&
765
883
  shellModuleAsset(release.ui.build) !== undefined
766
- ? `${uiUrl}${shellModuleAssetPath.slice(1)}`
884
+ ? `/ui-entry/${installation.installationId}/${installation.release.releaseId}${shellModuleAssetPath}`
767
885
  : null,
768
886
  storage: oysterPrincipalIsOwner(principal) &&
769
887
  host.workbench !== undefined &&
@@ -771,7 +889,7 @@ export function createOyster(host) {
771
889
  appsOpen: declaration?.capabilities.includes("apps.open") === true,
772
890
  computerId: invocationCallerContext(principal, host.computerId ?? null)
773
891
  .computerId,
774
- authorityVersion: installation.authorityVersion,
892
+ authorityVersion: installation.authority.authorityVersion,
775
893
  capabilities,
776
894
  commands: declaration?.commands ?? [],
777
895
  icon: declaration?.icon ?? null,
@@ -787,8 +905,8 @@ export function createOyster(host) {
787
905
  return Response.json(config, {
788
906
  headers: { "Cache-Control": "no-store", Vary: "Accept" },
789
907
  });
790
- const head = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(installation.installationId)} · ${escapeHtml(title)}</title><link rel="stylesheet" href="${(await assets).fonts.path}">`;
791
- const tail = `<script type="application/json" id="oyster-config">${JSON.stringify(config).replaceAll("<", "\\u003c")}</script><script type="module" src="${(await assets).page.path}"></script>`;
908
+ const head = `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml(installation.installationId)} · ${escapeHtml(title)}</title>${tabIcon}<link rel="stylesheet" href="${(await assets()).fonts.path}">`;
909
+ const tail = `<script type="application/json" id="oyster-config">${JSON.stringify(config).replaceAll("<", "\\u003c")}</script><script type="module" src="${(await assets()).page.path}"></script>`;
792
910
  // The home shell is trusted: the host's page script renders it in this
793
911
  // top-level document, and it mounts each App's sandboxed frame inside
794
912
  // that App's window. Every other App page is only its one framed App;
@@ -799,7 +917,9 @@ export function createOyster(host) {
799
917
  headers: {
800
918
  "Content-Type": "text/html; charset=utf-8",
801
919
  "Cache-Control": "no-store",
802
- "Content-Security-Policy": `default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src data:; frame-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
920
+ // worker-src would otherwise fall back to script-src 'self': trusted
921
+ // shell code must not outlive revocation in a Worker or Service Worker.
922
+ "Content-Security-Policy": `default-src 'none'; script-src 'self'; worker-src 'none'; style-src 'self' 'unsafe-inline'; font-src data:; frame-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
803
923
  },
804
924
  });
805
925
  });
@@ -886,11 +1006,15 @@ export function createOyster(host) {
886
1006
  return refusal(403, "Gill source is not admitted");
887
1007
  const current = await gateFacts(installation, release, context.get("principal"));
888
1008
  if (current.declaredOperations.digest !==
889
- installation.declaredOperationsDigest ||
1009
+ installation.authority.declaredOperationsDigest ||
890
1010
  current.declaredOperations.artifactDigest !==
891
1011
  installation.release.artifact.artifactDigest)
892
1012
  return refusal(403, "stale_authority");
893
- if (method.requires.some((capability) => grantsFor(installation, current.declaration, current.declaredOperations, capability) === null))
1013
+ if (method.requires.some((capability) => grantsFor({
1014
+ status: installation.status,
1015
+ authority: installation.authority,
1016
+ artifactDigest: installation.release.artifact.artifactDigest,
1017
+ }, current.declaration, current.declaredOperations, capability) === null))
894
1018
  return refusal(403, "requires_not_granted");
895
1019
  if (selected?.kind === "computer")
896
1020
  return Response.json({
@@ -1024,13 +1148,15 @@ export function createOyster(host) {
1024
1148
  host.installations.get(target.installationId),
1025
1149
  ]);
1026
1150
  if (currentCaller?.status !== "enabled" ||
1027
- currentCaller.authorityVersion !== caller.authorityVersion ||
1151
+ currentCaller.authority.authorityVersion !==
1152
+ caller.authority.authorityVersion ||
1028
1153
  currentCaller.release.artifact.artifactDigest !==
1029
1154
  caller.release.artifact.artifactDigest ||
1030
1155
  !(await allows(currentCaller, fresh)))
1031
1156
  return refusal(403, "app_call_not_allowed");
1032
1157
  if (currentTarget?.status !== "enabled" ||
1033
- currentTarget.authorityVersion !== target.authorityVersion ||
1158
+ currentTarget.authority.authorityVersion !==
1159
+ target.authority.authorityVersion ||
1034
1160
  currentTarget.release.releaseId !== target.release.releaseId ||
1035
1161
  currentTarget.release.artifact.artifactDigest !==
1036
1162
  target.release.artifact.artifactDigest)