@fungi.computer/oyster 0.0.0-stage → 0.0.1

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 (242) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1082 -2
  3. package/dist/agents.d.ts +114 -0
  4. package/dist/agents.js +322 -0
  5. package/dist/app-builds.d.ts +151 -0
  6. package/dist/app-builds.js +355 -0
  7. package/dist/app-sandbox.d.ts +2 -0
  8. package/dist/app-sandbox.js +2 -0
  9. package/dist/app-sources.d.ts +46 -0
  10. package/dist/app-sources.js +335 -0
  11. package/dist/app-storage.d.ts +141 -0
  12. package/dist/app-storage.js +281 -0
  13. package/dist/apps/gate.d.ts +105 -0
  14. package/dist/apps/gate.js +273 -0
  15. package/dist/apps/index.d.ts +5 -0
  16. package/dist/apps/index.js +5 -0
  17. package/dist/apps/installation.d.ts +248 -0
  18. package/dist/apps/installation.js +307 -0
  19. package/dist/apps/operations.d.ts +8 -0
  20. package/dist/apps/operations.js +98 -0
  21. package/dist/apps/tools.d.ts +33 -0
  22. package/dist/apps/tools.js +17 -0
  23. package/dist/auth.d.ts +11 -0
  24. package/dist/auth.js +93 -0
  25. package/dist/backend-request.d.ts +3 -0
  26. package/dist/backend-request.js +53 -0
  27. package/dist/build/build-jobs.d.ts +128 -0
  28. package/dist/build/build-jobs.js +197 -0
  29. package/dist/build/build-step.d.ts +26 -0
  30. package/dist/build/build-step.js +20 -0
  31. package/dist/build/bundled-html.d.ts +7 -0
  32. package/dist/build/bundled-html.js +64 -0
  33. package/dist/build/definition-emission.d.ts +34 -0
  34. package/dist/build/definition-emission.js +201 -0
  35. package/dist/build/effect.d.ts +231 -0
  36. package/dist/build/effect.js +396 -0
  37. package/dist/build/git-environment.d.ts +6 -0
  38. package/dist/build/git-environment.js +48 -0
  39. package/dist/build/html-recipe.d.ts +20 -0
  40. package/dist/build/html-recipe.js +51 -0
  41. package/dist/build/local-build.d.ts +57 -0
  42. package/dist/build/local-build.js +1078 -0
  43. package/dist/build/r2-artifact-writer.d.ts +31 -0
  44. package/dist/build/r2-artifact-writer.js +179 -0
  45. package/dist/build/snapshot-package-store.d.ts +2 -0
  46. package/dist/build/snapshot-package-store.js +21 -0
  47. package/dist/build/snapshot-program-scripts.d.ts +6 -0
  48. package/dist/build/snapshot-program-scripts.js +2 -0
  49. package/dist/build/submission-contract.d.ts +384 -0
  50. package/dist/build/submission-contract.js +70 -0
  51. package/dist/build/workspace-build.d.ts +50 -0
  52. package/dist/build/workspace-build.js +156 -0
  53. package/dist/build-contract.d.ts +4 -0
  54. package/dist/build-contract.js +4 -0
  55. package/dist/build-jobs.d.ts +2 -0
  56. package/dist/build-jobs.js +2 -0
  57. package/dist/build.d.ts +11 -0
  58. package/dist/build.js +13 -0
  59. package/dist/capability-descriptor.d.ts +46 -0
  60. package/dist/capability-descriptor.js +51 -0
  61. package/dist/catalog-frame.d.ts +39 -0
  62. package/dist/catalog-frame.js +72 -0
  63. package/dist/cause-class.d.ts +19 -0
  64. package/dist/cause-class.js +54 -0
  65. package/dist/cloudflare.d.ts +123 -0
  66. package/dist/cloudflare.js +391 -0
  67. package/dist/computer-terminal.d.ts +38 -0
  68. package/dist/computer-terminal.js +410 -0
  69. package/dist/computer.d.ts +60 -0
  70. package/dist/computer.js +33 -0
  71. package/dist/event-recipients.d.ts +57 -0
  72. package/dist/event-recipients.js +144 -0
  73. package/dist/facet-invocation.d.ts +4 -0
  74. package/dist/facet-invocation.js +45 -0
  75. package/dist/files.d.ts +42 -0
  76. package/dist/files.js +123 -0
  77. package/dist/frame-binding-wire-script.d.ts +2 -0
  78. package/dist/frame-binding-wire-script.js +1 -0
  79. package/dist/frame-binding-wire.d.ts +28 -0
  80. package/dist/frame-binding-wire.js +54 -0
  81. package/dist/git-admission.d.ts +52 -0
  82. package/dist/git-admission.js +188 -0
  83. package/dist/git-auth.d.ts +11 -0
  84. package/dist/git-auth.js +27 -0
  85. package/dist/git-door.d.ts +35 -0
  86. package/dist/git-door.js +108 -0
  87. package/dist/git-journal.d.ts +31 -0
  88. package/dist/git-journal.js +188 -0
  89. package/dist/git.d.ts +31 -0
  90. package/dist/git.js +64 -0
  91. package/dist/guest-policy.d.ts +9 -0
  92. package/dist/guest-policy.js +13 -0
  93. package/dist/host.d.ts +244 -0
  94. package/dist/host.js +1043 -0
  95. package/dist/index.d.ts +166 -0
  96. package/dist/index.js +320 -0
  97. package/dist/installation-consent.d.ts +79 -0
  98. package/dist/installation-consent.js +82 -0
  99. package/dist/internal/background-origins.d.ts +3 -0
  100. package/dist/internal/background-origins.js +8 -0
  101. package/dist/internal/delivery.d.ts +12 -0
  102. package/dist/internal/delivery.js +167 -0
  103. package/dist/internal/host-gate.d.ts +14 -0
  104. package/dist/internal/host-gate.js +62 -0
  105. package/dist/internal/registry-installations.d.ts +6 -0
  106. package/dist/internal/registry-installations.js +12 -0
  107. package/dist/invocation.d.ts +251 -0
  108. package/dist/invocation.js +377 -0
  109. package/dist/keys.d.ts +48 -0
  110. package/dist/keys.js +52 -0
  111. package/dist/mcp-owner.d.ts +27 -0
  112. package/dist/mcp-owner.js +143 -0
  113. package/dist/mcp.d.ts +89 -0
  114. package/dist/mcp.js +429 -0
  115. package/dist/media.d.ts +23 -0
  116. package/dist/media.js +12 -0
  117. package/dist/pack.d.ts +76 -0
  118. package/dist/pack.js +134 -0
  119. package/dist/page/capabilities.d.ts +23 -0
  120. package/dist/page/capabilities.js +256 -0
  121. package/dist/page/computer-handles.d.ts +79 -0
  122. package/dist/page/computer-handles.js +44 -0
  123. package/dist/page/lifetime.d.ts +6 -0
  124. package/dist/page/lifetime.js +19 -0
  125. package/dist/page/mount.d.ts +163 -0
  126. package/dist/page/mount.js +390 -0
  127. package/dist/page/storage.d.ts +3 -0
  128. package/dist/page/storage.js +82 -0
  129. package/dist/page/terminal.d.ts +5 -0
  130. package/dist/page/terminal.js +7 -0
  131. package/dist/page-script.d.ts +14 -0
  132. package/dist/page-script.js +5 -0
  133. package/dist/r2-files.d.ts +3 -0
  134. package/dist/r2-files.js +79 -0
  135. package/dist/release.d.ts +72 -0
  136. package/dist/release.js +164 -0
  137. package/dist/root-data.d.ts +86 -0
  138. package/dist/root-data.js +69 -0
  139. package/dist/shell/app-directory.d.ts +537 -0
  140. package/dist/shell/app-directory.js +195 -0
  141. package/dist/shell/browser-actions.d.ts +7 -0
  142. package/dist/shell/browser-actions.js +56 -0
  143. package/dist/shell/command-host.d.ts +112 -0
  144. package/dist/shell/command-host.js +502 -0
  145. package/dist/shell/commands.d.ts +25 -0
  146. package/dist/shell/commands.js +78 -0
  147. package/dist/shell/index.d.ts +8 -0
  148. package/dist/shell/index.js +8 -0
  149. package/dist/shell/keys.d.ts +41 -0
  150. package/dist/shell/keys.js +134 -0
  151. package/dist/shell/shell-module.d.ts +31 -0
  152. package/dist/shell/shell-module.js +37 -0
  153. package/dist/shell/window-manager.d.ts +124 -0
  154. package/dist/shell/window-manager.js +711 -0
  155. package/dist/shell/workbench-contract.d.ts +68 -0
  156. package/dist/shell/workbench-contract.js +1 -0
  157. package/dist/shell/workbench-host-types.d.ts +96 -0
  158. package/dist/shell/workbench-host-types.js +1 -0
  159. package/dist/shell/workbench-host.d.ts +34 -0
  160. package/dist/shell/workbench-host.js +347 -0
  161. package/dist/shell/workbench-model.d.ts +192 -0
  162. package/dist/shell/workbench-model.js +53 -0
  163. package/dist/shell/workbench.d.ts +4 -0
  164. package/dist/shell/workbench.js +2 -0
  165. package/dist/source/fork.d.ts +27 -0
  166. package/dist/source/fork.js +46 -0
  167. package/dist/source/index.d.ts +11 -0
  168. package/dist/source/index.js +11 -0
  169. package/dist/source/seed-receipt.d.ts +53 -0
  170. package/dist/source/seed-receipt.js +18 -0
  171. package/dist/source/team-source.d.ts +32 -0
  172. package/dist/source/team-source.js +42 -0
  173. package/dist/source/template-repository.d.ts +2 -0
  174. package/dist/source/template-repository.js +8 -0
  175. package/dist/source/template-write.d.ts +69 -0
  176. package/dist/source/template-write.js +344 -0
  177. package/dist/storage-frame.d.ts +64 -0
  178. package/dist/storage-frame.js +28 -0
  179. package/dist/tenant.d.ts +81 -0
  180. package/dist/tenant.js +71 -0
  181. package/dist/testing/capabilities.d.ts +29 -0
  182. package/dist/testing/capabilities.js +130 -0
  183. package/dist/testing/catalog.d.ts +3 -0
  184. package/dist/testing/catalog.js +53 -0
  185. package/dist/testing/index.d.ts +10 -0
  186. package/dist/testing/index.js +10 -0
  187. package/dist/testing/installation.d.ts +11 -0
  188. package/dist/testing/installation.js +25 -0
  189. package/dist/testing/native-worker.d.ts +21 -0
  190. package/dist/testing/native-worker.js +106 -0
  191. package/dist/testing/worker.d.ts +18 -0
  192. package/dist/testing/worker.js +67 -0
  193. package/dist/testing/workers-runtime.d.ts +24 -0
  194. package/dist/testing/workers-runtime.js +33 -0
  195. package/dist/webhook-delivery.d.ts +14 -0
  196. package/dist/webhook-delivery.js +17 -0
  197. package/dist/workers-git.d.ts +285 -0
  198. package/dist/workers-git.js +1174 -0
  199. package/dist/workers.d.ts +352 -0
  200. package/dist/workers.js +866 -0
  201. package/docs/api/README.md +82 -0
  202. package/docs/api/agents.md +332 -0
  203. package/docs/api/app-storage.md +209 -0
  204. package/docs/api/apps.md +1375 -0
  205. package/docs/api/auth.md +63 -0
  206. package/docs/api/build-contract.md +507 -0
  207. package/docs/api/build.md +1476 -0
  208. package/docs/api/cloudflare.md +421 -0
  209. package/docs/api/computer.md +203 -0
  210. package/docs/api/files.md +117 -0
  211. package/docs/api/git.md +470 -0
  212. package/docs/api/host.md +1328 -0
  213. package/docs/api/index.md +430 -0
  214. package/docs/api/invocation.md +738 -0
  215. package/docs/api/keys.md +175 -0
  216. package/docs/api/mcp.md +301 -0
  217. package/docs/api/media.md +40 -0
  218. package/docs/api/modules.md +42 -0
  219. package/docs/api/pack.md +138 -0
  220. package/docs/api/page/mount.md +298 -0
  221. package/docs/api/root-data.md +111 -0
  222. package/docs/api/shell/app-directory.md +332 -0
  223. package/docs/api/shell/command-host.md +224 -0
  224. package/docs/api/shell/commands.md +197 -0
  225. package/docs/api/shell/keys.md +183 -0
  226. package/docs/api/shell/shell-module.md +143 -0
  227. package/docs/api/shell/window-manager.md +267 -0
  228. package/docs/api/shell/workbench.md +186 -0
  229. package/docs/api/shell.md +1788 -0
  230. package/docs/api/tenant.md +194 -0
  231. package/docs/api/workers-git.md +1527 -0
  232. package/docs/api/workers.md +1678 -0
  233. package/docs/media/architecture.mmd +32 -0
  234. package/docs/media/architecture.png +0 -0
  235. package/docs/media/bonsai-network.png +0 -0
  236. package/docs/media/bonsai-repository.png +0 -0
  237. package/docs/site/agent-quickstart.mdx +349 -0
  238. package/examples/git/worker.ts +39 -0
  239. package/examples/git/wrangler.jsonc +32 -0
  240. package/examples/standalone/worker.ts +14 -0
  241. package/examples/standalone/wrangler.jsonc +21 -0
  242. package/package.json +249 -3
package/dist/host.js ADDED
@@ -0,0 +1,1043 @@
1
+ import { hostGateFacts, releaseCache } from "./internal/host-gate.js";
2
+ import { appSandbox } from "./app-sandbox.js";
3
+ import { sha256DigestSchema } from "@fungi.computer/puffball/effect";
4
+ import { shellModuleAsset, shellModuleAssetPath, } from "@fungi.computer/puffball/assets";
5
+ import { installationConsentSchema, capabilityDescriptions, } from "./installation-consent.js";
6
+ import { gitRepositoryNameSchema } from "@fungi.computer/puffball/git-representation";
7
+ import { gillRequestSchema, decodeGillReply, decodeDeclaredGillResponse, } from "@fungi.computer/gills";
8
+ import { causeClass } from "./cause-class.js";
9
+ import { appStorageKeyNameSchema, appStorageWriteSchema, appStorageResetSchema, } from "@fungi.computer/agar/storage";
10
+ import {} from "@fungi.computer/agar/git";
11
+ import { guestLimits } from "./guest-policy.js";
12
+ import { APP_INVOCATION_CEILING_MS, admittedCallerFacts, admittedMethodCallerFacts, invocationAuthorityOf, invocationCallerContext, invocationFacts, } from "./invocation.js";
13
+ import { appFacetResponse } from "./backend-request.js";
14
+ import { Hono } from "hono";
15
+ import { bodyLimit } from "hono/body-limit";
16
+ import { match } from "path-to-regexp";
17
+ import * as Schema from "effect/Schema";
18
+ import * as Result from "effect/Result";
19
+ import {} from "@fungi.computer/puffball/background-replay";
20
+ import { emptyAppDeclaration } from "@fungi.computer/puffball/app";
21
+ import { applyInstallationCommand, installationIdSchema, installationCommandSchema, } from "./apps/installation.js";
22
+ import { admits, scopedBindings, grantsFor } from "./apps/gate.js";
23
+ import { deriveDeclaredOperations, appCallOperation, } from "./apps/operations.js";
24
+ import { appBackendActorHeader, appBackendComputerHeader, appMethodRefusalStatus, } from "@fungi.computer/agar/backend";
25
+ import { appCallsDescriptor, } from "./capability-descriptor.js";
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";
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";
32
+ import { createElement } from "react";
33
+ import { renderToStaticMarkup } from "react-dom/server";
34
+ import { CenteredCard, CardHeader, CardTitle, CardContent, } from "@fungi.computer/caps/components/card";
35
+ import { Form, Field, FormFooter } from "@fungi.computer/caps/components/field";
36
+ import { Input } from "@fungi.computer/caps/components/input";
37
+ import { Button } from "@fungi.computer/caps/components/button";
38
+ import { parseArtifactRef } from "@fungi.computer/puffball";
39
+ export { ReleaseError, defaultAssetPolicy, parseRelease, resolveBackend, sha256, verifyRelease, } from "./release.js";
40
+ /** Narrow an admitted principal to the anonymous public reader. */
41
+ export function oysterPrincipalIsVisitor(principal) {
42
+ return principal.actor.kind === "visitor";
43
+ }
44
+ /** True only for a member admitted with the owner role. */
45
+ export function oysterPrincipalIsOwner(principal) {
46
+ return "role" in principal && principal.role === "owner";
47
+ }
48
+ /** Stable actor identity for host storage and backend attribution. */
49
+ export function oysterPrincipalId(principal) {
50
+ switch (principal.actor.kind) {
51
+ case "member":
52
+ return principal.actor.memberId;
53
+ case "agent":
54
+ return principal.actor.agentId;
55
+ case "team-key":
56
+ return principal.actor.keyId;
57
+ }
58
+ }
59
+ const installRequestSchema = Schema.Struct({
60
+ release: Schema.Unknown,
61
+ installationId: Schema.optional(Schema.String.pipe(Schema.check(Schema.isPattern(/^[a-z][a-z0-9-]{0,62}$/)))),
62
+ });
63
+ const maxBackendBody = 1024 * 1024;
64
+ const maxMethodBody = 64 * 1024;
65
+ class MethodBodyTooLarge extends Error {
66
+ }
67
+ async function methodInput(request) {
68
+ let bytes = 0;
69
+ const body = request.body?.pipeThrough(new TransformStream({
70
+ transform(chunk, controller) {
71
+ bytes += chunk.byteLength;
72
+ if (bytes > maxMethodBody)
73
+ throw new MethodBodyTooLarge();
74
+ controller.enqueue(chunk);
75
+ },
76
+ }));
77
+ return Schema.decodeUnknownSync(Schema.Json)(await new Response(body ?? null).json());
78
+ }
79
+ function escapeHtml(value) {
80
+ return value
81
+ .replaceAll("&", "&")
82
+ .replaceAll("<", "&lt;")
83
+ .replaceAll(">", "&gt;")
84
+ .replaceAll('"', "&quot;");
85
+ }
86
+ const visibilitySchema = Schema.Struct({
87
+ visibility: Schema.Literals(["private", "public"]),
88
+ });
89
+ function refusal(status, error, retryAfter) {
90
+ const init = { status };
91
+ if (retryAfter !== undefined)
92
+ init.headers = { "Retry-After": String(retryAfter) };
93
+ return Response.json({ error }, init);
94
+ }
95
+ 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
+ if (code === "failed") {
104
+ if (causeClass === undefined)
105
+ console.error("AppInstance failure", {
106
+ stage: "instance_execution",
107
+ code,
108
+ });
109
+ else
110
+ console.error("AppInstance failure", {
111
+ stage: "instance_execution",
112
+ code,
113
+ causeClass,
114
+ });
115
+ return refusal(502, "App execution failed");
116
+ }
117
+ return refusal(conflict.includes(code) ? 409 : 403, code);
118
+ }
119
+ /** An App's typed refusal keeps its code and caller-facing message. */
120
+ function methodResponse(reply) {
121
+ return reply.kind === "ok"
122
+ ? Response.json(reply.value)
123
+ : Response.json({ error: reply.code, message: reply.message }, { status: appMethodRefusalStatus[reply.code] });
124
+ }
125
+ /**
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.
131
+ */
132
+ export async function installOrSelect(host, installationId, release, principal) {
133
+ let verifiedRelease;
134
+ try {
135
+ verifiedRelease = await verifyRelease(host.artifacts, release);
136
+ }
137
+ catch (cause) {
138
+ return cause instanceof ReleaseError
139
+ ? refusal(422, cause.code)
140
+ : refusal(502, "Release could not be read");
141
+ }
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);
176
+ }
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
+ }
189
+ /**
190
+ * Create the Oyster App server: a Hono app that installs App Releases, serves
191
+ * their UI, forwards their backend requests and methods to their Instances,
192
+ * and mounts the host's MCP endpoint. The host's adapters decide where state
193
+ * lives and who may sign in.
194
+ */
195
+ export function createOyster(host) {
196
+ const limits = guestLimits(host.guestLimits);
197
+ const app = new Hono();
198
+ const releaseOf = releaseCache(host.artifacts);
199
+ const operationDescriptors = host.capabilities ?? [];
200
+ const gateFacts = (installation, release, principal) => hostGateFacts(host, installation, release, principal);
201
+ const reduceInstallation = async (current, command, release, principal, identity) => {
202
+ const context = invocationCallerContext(principal, host.computerId ?? null);
203
+ const declaration = release.declaration ?? emptyAppDeclaration;
204
+ return applyInstallationCommand(current, command, {
205
+ by: context.actor,
206
+ role: context.role ?? null,
207
+ expectedVersion: current?.authorityVersion ?? null,
208
+ declaration,
209
+ declaredOperations: await deriveDeclaredOperations(release.release, declaration, operationDescriptors),
210
+ now: Date.now(),
211
+ identity,
212
+ });
213
+ };
214
+ const title = host.title ?? "Oyster";
215
+ const asset = async (name, contentType, source) => ({
216
+ path: `/_oyster/assets/${(await sha256(new TextEncoder().encode(source))).slice(7)}/${name}`,
217
+ contentType,
218
+ source,
219
+ });
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,
232
+ }));
233
+ // App UI: digest-pinned, sandboxed, and readable without a session because
234
+ // the opaque-origin frame cannot carry one.
235
+ app.get("/ui/:installationId/:releaseId/*", async (context) => {
236
+ const installation = await host.installations.get(context.req.param("installationId"));
237
+ if (installation?.status !== "enabled" ||
238
+ installation.release.releaseId !== context.req.param("releaseId"))
239
+ return refusal(404, "Not found");
240
+ const release = await releaseOf(installation.release);
241
+ const prefix = `/ui/${installation.installationId}/${installation.release.releaseId}`;
242
+ const path = new URL(context.req.url).pathname.slice(prefix.length) || "/";
243
+ const origin = new URL(context.req.url).origin;
244
+ if (release.ui === null)
245
+ return refusal(404, "This App has no UI");
246
+ if (release.ui.kind === "html") {
247
+ if (path !== "/")
248
+ return refusal(404, "Not found");
249
+ const bytes = await readVerified(host.artifacts, release.ui.artifactRef, release.ui.digest);
250
+ return new Response(new Uint8Array(bytes), {
251
+ headers: {
252
+ "Content-Type": "text/html; charset=utf-8",
253
+ "Content-Security-Policy": `default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src blob: data:; media-src blob:; font-src data:; connect-src 'none'; frame-src 'none'; frame-ancestors ${origin}; base-uri 'none'; form-action 'none'; sandbox ${appSandbox}`,
254
+ "X-Content-Type-Options": "nosniff",
255
+ "Cache-Control": "no-store",
256
+ },
257
+ });
258
+ }
259
+ const wanted = path === "/" ? "/index.html" : path;
260
+ const asset = release.ui.build.assets.find((item) => item.path === wanted);
261
+ if (asset === undefined)
262
+ return refusal(404, "Not found");
263
+ const bytes = await readVerified(host.artifacts, asset.artifactRef, asset.digest);
264
+ const scope = `${origin}${prefix}/`;
265
+ return new Response(new Uint8Array(bytes), {
266
+ headers: {
267
+ "Content-Type": asset.contentType,
268
+ "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
+ "Access-Control-Allow-Origin": "*",
270
+ "X-Content-Type-Options": "nosniff",
271
+ "Cache-Control": "public, max-age=31536000, immutable",
272
+ },
273
+ });
274
+ });
275
+ app.get("/_oyster/assets/*", async (context) => {
276
+ const wanted = new URL(context.req.url).pathname;
277
+ const found = Object.values(await assets).find(({ path }) => path === wanted);
278
+ if (found === undefined)
279
+ return refusal(404, "Not found");
280
+ return new Response(found.source, {
281
+ headers: {
282
+ "Content-Type": found.contentType,
283
+ "Cache-Control": "public, max-age=31536000, immutable",
284
+ "X-Content-Type-Options": "nosniff",
285
+ },
286
+ });
287
+ });
288
+ app.get("/login", async () => {
289
+ const { fonts, styles } = await assets;
290
+ 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
+ label: "Owner key",
292
+ required: true,
293
+ children: createElement(Input, {
294
+ name: "key",
295
+ type: "password",
296
+ autoComplete: "current-password",
297
+ required: true,
298
+ }),
299
+ }), 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>`, {
301
+ headers: {
302
+ "Content-Type": "text/html; charset=utf-8",
303
+ "Cache-Control": "no-store",
304
+ },
305
+ });
306
+ });
307
+ app.post("/login", (context) => host.auth.login === undefined
308
+ ? refusal(404, "Sign-in is not available")
309
+ : host.auth.login(context.req.raw));
310
+ if (host.mcp !== undefined) {
311
+ const mcp = host.mcp;
312
+ app.all("/mcp", (context) => mcp.handleRequest(context.req.raw));
313
+ }
314
+ if (host.git !== undefined) {
315
+ const git = host.git;
316
+ app.route("/", createGitDoor({
317
+ authenticate: async (request, target) => {
318
+ // A Workflow Git pass header is admitted only by the pass port,
319
+ // never as a session or key.
320
+ const presented = presentedGitKey(request);
321
+ if (presented.kind === "key" &&
322
+ presented.value.startsWith(workflowGitPassPrefix))
323
+ return git.workflowPass === undefined
324
+ ? null
325
+ : git.workflowPass(presented.value, target);
326
+ // A build clone pass likewise reaches only the build pass port.
327
+ if (presented.kind === "key" &&
328
+ presented.value.startsWith(buildGitPassPrefix))
329
+ return git.buildPass === undefined
330
+ ? null
331
+ : git.buildPass(presented.value, target);
332
+ const result = await host.auth.authenticate(request);
333
+ return result.kind === "admitted"
334
+ ? git.writerOf(result.principal)
335
+ : null;
336
+ },
337
+ isPublic: async (installationId) => (await host.installations.get(installationId))?.visibility ===
338
+ "public",
339
+ git: async (installationId) => {
340
+ const installation = await host.installations.get(installationId);
341
+ if (installation?.status !== "enabled")
342
+ return null;
343
+ return git.git(installation);
344
+ },
345
+ }));
346
+ }
347
+ // A public installation's page and read methods admit anonymous visitors.
348
+ const visitor = { actor: { kind: "visitor" } };
349
+ const visitorRoute = /^\/apps\/([^/]+)\/(?:|methods\/([A-Za-z0-9_.]+))$/u;
350
+ const visitorMayReach = async (request) => {
351
+ const match = visitorRoute.exec(new URL(request.url).pathname);
352
+ if (match === null)
353
+ return null;
354
+ const [, installationId = "", method] = match;
355
+ const page = method === undefined;
356
+ if (page ? request.method !== "GET" : request.method !== "POST")
357
+ return null;
358
+ const installation = await host.installations.get(installationId);
359
+ if (installation?.status !== "enabled" ||
360
+ installation.visibility !== "public")
361
+ return null;
362
+ if (page)
363
+ return installation;
364
+ const release = await releaseOf(installation.release);
365
+ return release.api?.methods[method]?.effect === "read"
366
+ ? installation
367
+ : null;
368
+ };
369
+ // The public home and public App reads admit visitors; other routes require authentication.
370
+ app.use("*", async (context, next) => {
371
+ const result = await host.auth.authenticate(context.req.raw);
372
+ const installation = result.kind === "refused" ? await visitorMayReach(context.req.raw) : null;
373
+ if (result.kind === "refused" &&
374
+ (result.status === 401 || result.status === 403) &&
375
+ ((context.req.method === "GET" && context.req.path === "/") ||
376
+ installation !== null)) {
377
+ if (installation !== null)
378
+ context.set("installation", installation);
379
+ context.set("principal", visitor);
380
+ await next();
381
+ return undefined;
382
+ }
383
+ if (result.kind === "refused") {
384
+ const accept = context.req.header("Accept") ?? "";
385
+ if ((result.status === 401 || result.status === 403) &&
386
+ context.req.method === "GET" &&
387
+ accept.includes("text/html"))
388
+ return context.redirect("/login");
389
+ return refusal(result.status, "Sign in to use this Oyster", result.retryAfter);
390
+ }
391
+ context.set("principal", result.principal);
392
+ await next();
393
+ return undefined;
394
+ });
395
+ const capabilityRoutes = (host.capabilities ?? []).flatMap((capability) => Object.entries(capability.methods).map(([name, method]) => ({
396
+ capability,
397
+ operation: `route:${capability.id}:${name}`,
398
+ method: method.method,
399
+ kind: method.kind,
400
+ matches: match(`${capability.base}${method.path}`, {
401
+ end: method.kind !== "transport",
402
+ sensitive: true,
403
+ }),
404
+ })));
405
+ // Capability routes run under the principal stamped above; they receive the
406
+ // admission as their env and never authenticate again.
407
+ app.use("*", async (context, next) => {
408
+ const url = new URL(context.req.url);
409
+ const candidates = capabilityRoutes.filter((route) => (route.method === "ANY" || route.method === context.req.method) &&
410
+ route.matches(url.pathname));
411
+ if (candidates.length === 0)
412
+ return next();
413
+ const principal = context.get("principal");
414
+ if (oysterPrincipalIsVisitor(principal))
415
+ return refusal(401, "Sign in to use capabilities");
416
+ const browserTransport = context.req.header("Upgrade")?.toLowerCase() === "websocket" &&
417
+ context.req.header("Origin") === url.origin &&
418
+ candidates.some((route) => route.kind === "transport");
419
+ const installationId = context.req.header("X-Oyster-Installation") ??
420
+ (browserTransport ? url.searchParams.get("oysterInstallation") : null);
421
+ const installation = installationId === null
422
+ ? null
423
+ : await host.installations.get(installationId);
424
+ if (installation?.status !== "enabled")
425
+ return refusal(403, "Capability permission denied");
426
+ const facts = await gateFacts(installation, await releaseOf(installation.release), principal);
427
+ for (const { capability, operation } of candidates) {
428
+ if (admits(facts, {
429
+ kind: "capability-route",
430
+ capability: capability.permission,
431
+ operation,
432
+ }).kind === "refused")
433
+ continue;
434
+ url.pathname = `/${url.pathname.slice(capability.base.length)}`;
435
+ return capability.routes.fetch(new Request(url, context.req.raw), {
436
+ principal,
437
+ installation,
438
+ });
439
+ }
440
+ return refusal(403, "Capability permission denied");
441
+ });
442
+ app.get("/", async (context) => {
443
+ const publicHome = context.get("principal").actor.kind === "visitor";
444
+ if (!publicHome && host.homeInstallationId !== undefined) {
445
+ const home = await host.installations.get(host.homeInstallationId);
446
+ if (home?.status === "enabled")
447
+ return context.redirect(`/apps/${encodeURIComponent(home.installationId)}/`);
448
+ }
449
+ const installations = (await host.installations.list()).filter((installation) => installation.status === "enabled" &&
450
+ (!publicHome || installation.visibility === "public"));
451
+ if (publicHome) {
452
+ const apps = [];
453
+ for (const installation of installations) {
454
+ const release = await releaseOf(installation.release);
455
+ if (release.ui === null)
456
+ continue;
457
+ apps.push({
458
+ href: `/apps/${encodeURIComponent(installation.installationId)}/`,
459
+ displayName: release.declaration?.displayName ?? "App",
460
+ icon: release.declaration?.icon ?? null,
461
+ });
462
+ }
463
+ 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>`, {
465
+ headers: {
466
+ "Content-Type": "text/html; charset=utf-8",
467
+ "Cache-Control": "no-store",
468
+ "X-Content-Type-Options": "nosniff",
469
+ "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'",
470
+ },
471
+ });
472
+ }
473
+ const items = installations
474
+ .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
+ .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>`, {
477
+ headers: {
478
+ "Content-Type": "text/html; charset=utf-8",
479
+ "Cache-Control": "no-store",
480
+ "X-Content-Type-Options": "nosniff",
481
+ },
482
+ });
483
+ });
484
+ app.use("/api/*", async (context, next) => {
485
+ if (!oysterPrincipalIsOwner(context.get("principal")))
486
+ return refusal(403, "Only the owner manages this Oyster");
487
+ await next();
488
+ return undefined;
489
+ });
490
+ app.put("/api/installations/:installationId/visibility", async (context) => {
491
+ const current = await host.installations.get(context.req.param("installationId"));
492
+ if (current === null || current.status === "deleted")
493
+ return refusal(404, "Not found");
494
+ const body = Schema.decodeUnknownOption(visibilitySchema)(await context.req.json());
495
+ if (body._tag === "None")
496
+ return refusal(400, "Invalid visibility");
497
+ const update = await reduceInstallation(current, { kind: "set-visibility", visibility: body.value.visibility }, await releaseOf(current.release), context.get("principal"), current);
498
+ if (Result.isFailure(update))
499
+ return refusal(409, update.failure.code);
500
+ const next = update.success.next;
501
+ const refused = await apply(next, await releaseOf(current.release));
502
+ if (refused !== null)
503
+ return refused;
504
+ await host.installations.put(next);
505
+ return Response.json(next);
506
+ });
507
+ if (host.ownerRoutes !== undefined) {
508
+ const ownerRoutes = host.ownerRoutes;
509
+ const owned = new Hono();
510
+ owned.use("/api/*", async (context, next) => {
511
+ if (!oysterPrincipalIsOwner(context.get("principal")))
512
+ return refusal(403, "Owner only");
513
+ await next();
514
+ return undefined;
515
+ });
516
+ owned.route("/", ownerRoutes);
517
+ app.route("/", owned);
518
+ }
519
+ app.get("/api/workbench/directory", async (context) => {
520
+ if (!oysterPrincipalIsOwner(context.get("principal")))
521
+ return refusal(403, "Owner only");
522
+ if (host.workbench === undefined)
523
+ return refusal(404, "Not found");
524
+ let directory = await host.workbench.directory();
525
+ if (host.computerId !== undefined)
526
+ directory = {
527
+ ...directory,
528
+ computer: { computerId: host.computerId, name: host.computerId },
529
+ };
530
+ return Response.json(directory, {
531
+ headers: { "Cache-Control": "no-store" },
532
+ });
533
+ });
534
+ app.all("/api/workbench/storage/:installationId/:operation", async (context) => {
535
+ const principal = context.get("principal");
536
+ if (!oysterPrincipalIsOwner(principal))
537
+ return refusal(403, "Owner only");
538
+ const workbench = host.workbench;
539
+ if (workbench === undefined)
540
+ return refusal(404, "Not found");
541
+ const view = workbench.storage(context.req.param("installationId"), oysterPrincipalId(principal));
542
+ const operation = context.req.param("operation");
543
+ if ((operation === "read" || operation === "events") &&
544
+ context.req.method === "GET") {
545
+ const key = Schema.decodeUnknownOption(appStorageKeyNameSchema)(context.req.query("key"));
546
+ if (key._tag === "None")
547
+ return refusal(400, "Invalid storage key");
548
+ return operation === "read"
549
+ ? Response.json(await view.read(key.value))
550
+ : view.events(key.value);
551
+ }
552
+ if ((operation === "write" || operation === "reset") &&
553
+ context.req.method === "POST") {
554
+ if (operation === "write") {
555
+ const input = Schema.decodeUnknownOption(appStorageWriteSchema, {
556
+ onExcessProperty: "error",
557
+ })(await context.req.json());
558
+ return input._tag === "None"
559
+ ? refusal(400, "Invalid storage write")
560
+ : Response.json(await view.write(input.value));
561
+ }
562
+ const input = Schema.decodeUnknownOption(appStorageResetSchema, {
563
+ onExcessProperty: "error",
564
+ })(await context.req.json());
565
+ return input._tag === "None"
566
+ ? refusal(400, "Invalid storage reset")
567
+ : Response.json(await view.reset(input.value));
568
+ }
569
+ return refusal(404, "Not found");
570
+ });
571
+ app.get("/api/installations", async () => Response.json(await host.installations.list()));
572
+ app.put("/api/artifacts/:artifactRef", async (context) => {
573
+ let artifactRef;
574
+ try {
575
+ artifactRef = parseArtifactRef(context.req.param("artifactRef"));
576
+ }
577
+ catch {
578
+ return refusal(400, "Invalid artifact reference");
579
+ }
580
+ const expected = context.req.query("digest");
581
+ const bytes = new Uint8Array(await context.req.arrayBuffer());
582
+ if (expected === undefined || (await sha256(bytes)) !== expected)
583
+ return refusal(422, "Artifact bytes do not match the digest");
584
+ await host.artifacts.put(artifactRef, bytes);
585
+ return new Response(null, { status: 204 });
586
+ });
587
+ const apply = async (installation, verifiedRelease) => {
588
+ if (verifiedRelease.backend === null)
589
+ return null;
590
+ const result = await host.instances
591
+ .get(selectionOf(installation).instanceId, limits)
592
+ .apply({ ...selectionOf(installation), status: installation.status }, installation.release);
593
+ return result.ok ? null : instanceRefusal(result.code, result.causeClass);
594
+ };
595
+ app.post("/api/installations", async (context) => {
596
+ let request;
597
+ let release;
598
+ try {
599
+ request = Schema.decodeUnknownSync(installRequestSchema)(await context.req.json());
600
+ release = parseRelease(request.release);
601
+ }
602
+ catch {
603
+ return refusal(400, "Invalid install request");
604
+ }
605
+ const installationId = request.installationId ?? release.appId.replaceAll("_", "-");
606
+ return installOrSelect(host, installationId, release, context.get("principal"));
607
+ });
608
+ app.get("/api/installations/:installationId/grants", async (context) => {
609
+ if (context.req.raw.headers.has("X-Oyster-Installation"))
610
+ return refusal(403, "Owner direct only");
611
+ const current = await host.installations.get(context.req.param("installationId"));
612
+ if (current === null || current.status === "deleted")
613
+ return refusal(404, "Not found");
614
+ const verified = await releaseOf(current.release);
615
+ const declaration = verified.declaration ?? emptyAppDeclaration;
616
+ const declared = await deriveDeclaredOperations(current.release, declaration, operationDescriptors);
617
+ const config = Schema.decodeUnknownSync(installationConsentSchema)({
618
+ installationId: current.installationId,
619
+ appId: current.appId,
620
+ appName: declaration.displayName ?? current.installationId,
621
+ authorityVersion: current.authorityVersion,
622
+ 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
+ }),
641
+ });
642
+ 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>`, {
644
+ headers: {
645
+ "Content-Type": "text/html; charset=utf-8",
646
+ "Cache-Control": "no-store",
647
+ "X-Content-Type-Options": "nosniff",
648
+ "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'",
649
+ },
650
+ });
651
+ });
652
+ app.put("/api/installations/:installationId/grants", bodyLimit({
653
+ maxSize: 64 * 1024,
654
+ onError: () => refusal(413, "Approval request too large"),
655
+ }), async (context) => {
656
+ // Capability-origin requests cannot become direct owner consent.
657
+ if (context.req.raw.headers.has("X-Oyster-Installation"))
658
+ return refusal(403, "Owner direct only");
659
+ const principal = context.get("principal");
660
+ if (!oysterPrincipalIsOwner(principal))
661
+ 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")
668
+ return refusal(400, "Invalid approval");
669
+ const command = installationCommandSchema.safeParse(parsed.value.command);
670
+ if (!command.success || command.data.kind !== "set-grants")
671
+ return refusal(400, "Invalid approval");
672
+ const current = await host.installations.get(context.req.param("installationId"));
673
+ if (current === null || current.status === "deleted")
674
+ return refusal(404, "Not found");
675
+ if (current.authorityVersion !== parsed.value.authorityVersion ||
676
+ current.release.artifact.artifactDigest !== parsed.value.artifactDigest)
677
+ return refusal(409, "stale_authority");
678
+ const release = await releaseOf(current.release);
679
+ const update = await reduceInstallation(current, command.data, release, principal, current);
680
+ if (Result.isFailure(update))
681
+ return refusal(409, update.failure.code);
682
+ const commit = await host.installations.compareAndPut(update.success.next, parsed.value.authorityVersion);
683
+ if (!commit.ok)
684
+ return refusal(409, commit.code);
685
+ const refused = await apply(update.success.next, release);
686
+ if (refused !== null)
687
+ return refused;
688
+ return Response.json(update.success.next);
689
+ });
690
+ app.put("/api/installations/:installationId/release", async (context) => {
691
+ let release;
692
+ try {
693
+ release = parseRelease(await context.req.json());
694
+ }
695
+ catch {
696
+ return refusal(400, "Invalid Release");
697
+ }
698
+ return installOrSelect(host, context.req.param("installationId"), release, context.get("principal"));
699
+ });
700
+ app.delete("/api/installations/:installationId", async (context) => {
701
+ const current = await host.installations.get(context.req.param("installationId"));
702
+ if (current === null || current.status === "deleted")
703
+ return refusal(404, "Not found");
704
+ const update = await reduceInstallation(current, { kind: "set-status", status: "deleted" }, await releaseOf(current.release), context.get("principal"), current);
705
+ if (Result.isFailure(update))
706
+ return refusal(409, update.failure.code);
707
+ const next = update.success.next;
708
+ const refused = await apply(next, await releaseOf(current.release));
709
+ if (refused !== null)
710
+ return refused;
711
+ await host.installations.put(next);
712
+ return new Response(null, { status: 204 });
713
+ });
714
+ const enabled = async (context) => {
715
+ const installationId = context.req.param("installationId") ?? "";
716
+ const resolved = context.get("installation");
717
+ const installation = resolved?.installationId === installationId
718
+ ? resolved
719
+ : await host.installations.get(installationId);
720
+ return installation?.status === "enabled" ? installation : null;
721
+ };
722
+ app.get("/apps/:installationId/", async (context) => {
723
+ const installation = await enabled(context);
724
+ if (installation === null)
725
+ return refusal(404, "Not found");
726
+ const release = await releaseOf(installation.release);
727
+ const declaration = release.declaration;
728
+ const principal = context.get("principal");
729
+ const facts = await gateFacts(installation, release, principal);
730
+ const capabilities = [];
731
+ for (const capability of host.capabilities ?? []) {
732
+ const methods = Object.fromEntries(Object.entries(capability.methods).filter(([name]) => admits(facts, {
733
+ kind: "capability-route",
734
+ capability: capability.permission,
735
+ operation: `route:${capability.id}:${name}`,
736
+ }).kind === "admitted"));
737
+ if (Object.keys(methods).length > 0) {
738
+ const { id, version, base } = capability;
739
+ capabilities.push({ id, version, base, methods });
740
+ }
741
+ }
742
+ if (principal.actor.kind === "member" &&
743
+ (declaration?.calls?.length ?? 0) > 0 &&
744
+ declaration?.calls?.some((call) => call.methods.some((method) => admits(facts, {
745
+ kind: "capability-route",
746
+ capability: "apps.call",
747
+ operation: appCallOperation(call.appId, method),
748
+ }).kind === "admitted"))) {
749
+ capabilities.push({
750
+ ...appCallsDescriptor,
751
+ base: `/apps/${encodeURIComponent(installation.installationId)}/calls/`,
752
+ });
753
+ }
754
+ const workbench = oysterPrincipalIsOwner(principal) &&
755
+ host.workbench !== undefined &&
756
+ installation.installationId === host.homeInstallationId &&
757
+ declaration?.capabilities.includes("team.workbenchHost") === true;
758
+ const uiUrl = `/ui/${installation.installationId}/${installation.release.releaseId}/`;
759
+ const config = {
760
+ workbench,
761
+ // The home shell's own code: the module its Release ships beside
762
+ // index.html. Null when that Release ships none; the page then refuses.
763
+ shellModuleUrl: workbench &&
764
+ release.ui?.kind === "assets" &&
765
+ shellModuleAsset(release.ui.build) !== undefined
766
+ ? `${uiUrl}${shellModuleAssetPath.slice(1)}`
767
+ : null,
768
+ storage: oysterPrincipalIsOwner(principal) &&
769
+ host.workbench !== undefined &&
770
+ (declaration?.storage?.length ?? 0) > 0,
771
+ appsOpen: declaration?.capabilities.includes("apps.open") === true,
772
+ computerId: invocationCallerContext(principal, host.computerId ?? null)
773
+ .computerId,
774
+ authorityVersion: installation.authorityVersion,
775
+ capabilities,
776
+ commands: declaration?.commands ?? [],
777
+ icon: declaration?.icon ?? null,
778
+ roles: declaration?.roles ?? [],
779
+ installationId: installation.installationId,
780
+ appId: installation.release.appId,
781
+ releaseId: installation.release.releaseId,
782
+ uiUrl,
783
+ backendUrl: `/apps/${installation.installationId}/backend`,
784
+ methodsUrl: `/apps/${installation.installationId}/methods/`,
785
+ };
786
+ if (context.req.header("Accept") === "application/json")
787
+ return Response.json(config, {
788
+ headers: { "Cache-Control": "no-store", Vary: "Accept" },
789
+ });
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>`;
792
+ // The home shell is trusted: the host's page script renders it in this
793
+ // top-level document, and it mounts each App's sandboxed frame inside
794
+ // that App's window. Every other App page is only its one framed App;
795
+ // desktop chrome belongs to the shell.
796
+ return new Response(config.workbench
797
+ ? `${head}<style>html,body,#oyster-shell{margin:0;height:100%}body{font-family:var(--font-sans)}</style><div id="oyster-shell"></div>${tail}`
798
+ : `${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}`, {
799
+ headers: {
800
+ "Content-Type": "text/html; charset=utf-8",
801
+ "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'`,
803
+ },
804
+ });
805
+ });
806
+ app.put("/apps/:installationId/backend", async (context) => {
807
+ const principal = context.get("principal");
808
+ if (oysterPrincipalIsVisitor(principal))
809
+ return refusal(403, "Sign in to use this App");
810
+ const installation = await enabled(context);
811
+ if (installation === null)
812
+ return refusal(404, "Not found");
813
+ const release = await releaseOf(installation.release);
814
+ const decision = admits(await gateFacts(installation, release, principal), {
815
+ kind: "backend",
816
+ });
817
+ if (decision.kind === "refused")
818
+ return refusal(403, decision.code);
819
+ const body = await context.req.text();
820
+ if (new TextEncoder().encode(body).byteLength > maxBackendBody)
821
+ return refusal(413, "Request too large");
822
+ const headers = new Headers({
823
+ "Content-Type": "text/plain",
824
+ [appBackendActorHeader]: oysterPrincipalId(principal),
825
+ });
826
+ if (host.computerId !== undefined)
827
+ headers.set(appBackendComputerHeader, host.computerId);
828
+ const request = new Request("https://app.internal/", {
829
+ method: "PUT",
830
+ headers,
831
+ body,
832
+ });
833
+ const response = await host.instances
834
+ .get(selectionOf(installation).instanceId, limits)
835
+ .fetch(admittedCallerFacts(installation, release, invocationCallerContext(principal, host.computerId ?? null), decision.grants), request);
836
+ return appFacetResponse(response);
837
+ });
838
+ app.put("/apps/:installationId/gills", async (context) => {
839
+ const caller = invocationCallerContext(context.get("principal"), host.computerId ?? null);
840
+ if (caller.actor.kind !== "member" || caller.role !== "owner")
841
+ return refusal(403, "Owner only");
842
+ const installation = await enabled(context);
843
+ if (installation === null)
844
+ return refusal(404, "Not found");
845
+ const body = await context.req.text();
846
+ if (new TextEncoder().encode(body).byteLength > maxBackendBody)
847
+ return refusal(413, "Request too large");
848
+ const decoded = Schema.decodeUnknownOption(Schema.fromJsonString(gillRequestSchema), { onExcessProperty: "error" })(body);
849
+ if (decoded._tag === "None")
850
+ return refusal(400, "Invalid Gill request");
851
+ const request = decoded.value;
852
+ const release = await releaseOf(installation.release);
853
+ const gill = release.declaration?.gills?.find((item) => item.id === request.viewId);
854
+ if (gill === undefined || release.api === null || release.backend === null)
855
+ return refusal(404, "Gill is not declared");
856
+ if (installation.instanceId === null)
857
+ return instanceRefusal("invalid_invocation_facts");
858
+ const params = request.params ?? {};
859
+ const declared = gill.params ?? [];
860
+ if (Object.keys(params).some((key) => !declared.some((param) => param.key === key)))
861
+ return refusal(400, "Undeclared Gill parameter");
862
+ for (const param of declared) {
863
+ if (param.kind === "repository" &&
864
+ params[param.key] !== undefined &&
865
+ !gitRepositoryNameSchema.safeParse(params[param.key]).success)
866
+ return refusal(400, "Invalid repository parameter");
867
+ if (param.kind === "computer" &&
868
+ params[param.key] !== undefined &&
869
+ params[param.key] !== host.computerId)
870
+ return refusal(403, "Computer is not admitted");
871
+ }
872
+ const selected = request.options === undefined
873
+ ? null
874
+ : declared.find((param) => param.key === request.options);
875
+ if (request.options !== undefined && selected === undefined)
876
+ return refusal(400, "Undeclared Gill options");
877
+ const source = selected === null || selected?.kind === "computer"
878
+ ? gill.source
879
+ : selected?.options;
880
+ const method = source !== undefined && Object.hasOwn(release.api.methods, source)
881
+ ? release.api.methods[source]
882
+ : undefined;
883
+ if (source === undefined ||
884
+ method?.effect !== "read" ||
885
+ !method.requires.every((capability) => release.declaration?.capabilities.includes(capability)))
886
+ return refusal(403, "Gill source is not admitted");
887
+ const current = await gateFacts(installation, release, context.get("principal"));
888
+ if (current.declaredOperations.digest !==
889
+ installation.declaredOperationsDigest ||
890
+ current.declaredOperations.artifactDigest !==
891
+ installation.release.artifact.artifactDigest)
892
+ return refusal(403, "stale_authority");
893
+ if (method.requires.some((capability) => grantsFor(installation, current.declaration, current.declaredOperations, capability) === null))
894
+ return refusal(403, "requires_not_granted");
895
+ if (selected?.kind === "computer")
896
+ return Response.json({
897
+ options: host.computerId === undefined
898
+ ? []
899
+ : [{ value: host.computerId, label: host.computerId }],
900
+ });
901
+ const missing = declared.find((param) => param.required && !params[param.key]);
902
+ if (request.options === undefined && missing !== undefined)
903
+ return Response.json({
904
+ viewId: gill.id,
905
+ title: gill.title,
906
+ state: "error",
907
+ rows: [],
908
+ reason: "params_required",
909
+ error: `Choose a ${missing.kind}`,
910
+ });
911
+ const authority = invocationAuthorityOf(installation, release);
912
+ const grants = scopedBindings(authority.grants, method, "read");
913
+ const deadlineAt = Date.now() + APP_INVOCATION_CEILING_MS;
914
+ const facts = invocationFacts.fromCaller({ ...authority, grants }, caller, {
915
+ deadlineAt,
916
+ grantExpiresAt: deadlineAt,
917
+ }, { name: source, effect: "read" });
918
+ const { viewId, ...selectors } = request;
919
+ const result = await host.instances
920
+ .get(facts.instanceId, limits)
921
+ .gill(facts, release.api, source, { id: viewId, ...selectors });
922
+ if (!result.ok)
923
+ return instanceRefusal(result.code, result.causeClass);
924
+ try {
925
+ const reply = decodeGillReply(result.value);
926
+ return Response.json("viewId" in reply ? decodeDeclaredGillResponse(reply, gill) : reply);
927
+ }
928
+ catch (error) {
929
+ return instanceRefusal("failed", causeClass(error));
930
+ }
931
+ });
932
+ const executeMethod = async (installation, release, principal, name, request, recheck) => {
933
+ if (recheck !== undefined) {
934
+ const fresh = await recheck();
935
+ if (fresh instanceof Response)
936
+ return fresh;
937
+ principal = fresh;
938
+ }
939
+ if (release.api === null)
940
+ return refusal(404, "This App declares no methods");
941
+ let input;
942
+ try {
943
+ input = await methodInput(request);
944
+ }
945
+ catch (error) {
946
+ if (error instanceof MethodBodyTooLarge)
947
+ return refusal(413, "Request too large");
948
+ return refusal(400, "Method input must be JSON");
949
+ }
950
+ if (recheck !== undefined) {
951
+ const fresh = await recheck();
952
+ if (fresh instanceof Response)
953
+ return fresh;
954
+ principal = fresh;
955
+ }
956
+ const decision = admits(await gateFacts(installation, release, principal), {
957
+ kind: "method",
958
+ name: name,
959
+ input,
960
+ via: "ui",
961
+ });
962
+ if (decision.kind === "refused")
963
+ return refusal(decision.code === "invalid_input" ? 400 : 403, decision.code);
964
+ const method = decision.method;
965
+ if (method === undefined)
966
+ return refusal(403, "not_declared");
967
+ const result = await host.instances
968
+ .get(selectionOf(installation).instanceId, limits)
969
+ .call(await admittedMethodCallerFacts(host, installation, release, invocationCallerContext(principal, host.computerId ?? null), decision.grants, { name, effect: method.effect }), release.api, name, input);
970
+ if (!result.ok)
971
+ return instanceRefusal(result.code, result.causeClass);
972
+ return methodResponse(result.value);
973
+ };
974
+ app.post("/apps/:installationId/methods/:name", async (context) => {
975
+ const installation = await enabled(context);
976
+ if (installation === null)
977
+ return refusal(404, "Not found");
978
+ return executeMethod(installation, await releaseOf(installation.release), context.get("principal"), context.req.param("name"), context.req.raw);
979
+ });
980
+ app.post("/apps/:caller/calls/:appId/:method", async (context) => {
981
+ const principal = context.get("principal");
982
+ if (oysterPrincipalIsVisitor(principal) ||
983
+ principal.actor.kind !== "member")
984
+ return refusal(403, "app_call_member_only");
985
+ const caller = await host.installations.get(context.req.param("caller"));
986
+ if (caller?.status !== "enabled")
987
+ return refusal(404, "Not found");
988
+ const appId = context.req.param("appId");
989
+ const method = context.req.param("method");
990
+ const allows = async (installation, member) => {
991
+ const release = await releaseOf(installation.release);
992
+ if (!release.declaration?.calls?.some((call) => call.appId === appId && call.methods.includes(method)))
993
+ return false;
994
+ return (admits(await gateFacts(installation, release, member), {
995
+ kind: "capability-route",
996
+ capability: "apps.call",
997
+ operation: appCallOperation(appId, method),
998
+ }).kind === "admitted");
999
+ };
1000
+ if (!(await allows(caller, principal)))
1001
+ return refusal(403, "app_call_not_allowed");
1002
+ const targets = (await host.installations.list()).filter((installation) => installation.status === "enabled" &&
1003
+ installation.release.appId === appId);
1004
+ if (targets.length !== 1)
1005
+ return refusal(409, targets.length === 0
1006
+ ? "app_call_target_unavailable"
1007
+ : "app_call_target_ambiguous");
1008
+ const target = targets[0];
1009
+ if (target === undefined)
1010
+ return refusal(409, "app_call_target_unavailable");
1011
+ const release = await releaseOf(target.release);
1012
+ const admittedMember = invocationCallerContext(principal, host.computerId ?? null);
1013
+ const recheck = async () => {
1014
+ const fresh = await host.auth.resolve(admittedMember.actor);
1015
+ if (fresh === null)
1016
+ return refusal(403, "app_call_member_only");
1017
+ const currentMember = invocationCallerContext(fresh, host.computerId ?? null);
1018
+ if (currentMember.actor.kind !== "member" ||
1019
+ currentMember.actor.memberId !== oysterPrincipalId(principal) ||
1020
+ currentMember.role !== admittedMember.role)
1021
+ return refusal(403, "app_call_member_only");
1022
+ const [currentCaller, currentTarget] = await Promise.all([
1023
+ host.installations.get(caller.installationId),
1024
+ host.installations.get(target.installationId),
1025
+ ]);
1026
+ if (currentCaller?.status !== "enabled" ||
1027
+ currentCaller.authorityVersion !== caller.authorityVersion ||
1028
+ currentCaller.release.artifact.artifactDigest !==
1029
+ caller.release.artifact.artifactDigest ||
1030
+ !(await allows(currentCaller, fresh)))
1031
+ return refusal(403, "app_call_not_allowed");
1032
+ if (currentTarget?.status !== "enabled" ||
1033
+ currentTarget.authorityVersion !== target.authorityVersion ||
1034
+ currentTarget.release.releaseId !== target.release.releaseId ||
1035
+ currentTarget.release.artifact.artifactDigest !==
1036
+ target.release.artifact.artifactDigest)
1037
+ return refusal(403, "stale_authority");
1038
+ return fresh;
1039
+ };
1040
+ return executeMethod(target, release, principal, method, context.req.raw, recheck);
1041
+ });
1042
+ return app;
1043
+ }