@fungi.computer/puffball 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 (110) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +443 -2
  3. package/dist/app-api.d.ts +7 -0
  4. package/dist/app-api.js +61 -0
  5. package/dist/app-backend-binding-vocabulary.d.ts +299 -0
  6. package/dist/app-backend-binding-vocabulary.js +257 -0
  7. package/dist/app-backend-bindings.d.ts +21 -0
  8. package/dist/app-backend-bindings.js +47 -0
  9. package/dist/app-declaration.d.ts +46 -0
  10. package/dist/app-declaration.js +115 -0
  11. package/dist/app-event-facts.d.ts +40 -0
  12. package/dist/app-event-facts.js +28 -0
  13. package/dist/app-method-context.d.ts +109 -0
  14. package/dist/app-method-context.js +9 -0
  15. package/dist/app-method-runtime.d.ts +86 -0
  16. package/dist/app-method-runtime.js +122 -0
  17. package/dist/app-origin-paths.d.ts +6 -0
  18. package/dist/app-origin-paths.js +6 -0
  19. package/dist/app-vocabulary.d.ts +189 -0
  20. package/dist/app-vocabulary.js +103 -0
  21. package/dist/app.d.ts +379 -0
  22. package/dist/app.js +71 -0
  23. package/dist/artifact-dispositions.d.ts +115 -0
  24. package/dist/artifact-dispositions.js +234 -0
  25. package/dist/assets.d.ts +54 -0
  26. package/dist/assets.js +89 -0
  27. package/dist/backend.d.ts +41 -0
  28. package/dist/backend.js +29 -0
  29. package/dist/background-replay.d.ts +115 -0
  30. package/dist/background-replay.js +175 -0
  31. package/dist/buyer-references.d.ts +69 -0
  32. package/dist/buyer-references.js +154 -0
  33. package/dist/cloudflare-assets.d.ts +48 -0
  34. package/dist/cloudflare-assets.js +105 -0
  35. package/dist/cloudflare.d.ts +254 -0
  36. package/dist/cloudflare.js +1619 -0
  37. package/dist/effect.d.ts +73 -0
  38. package/dist/effect.js +325 -0
  39. package/dist/git-representation.d.ts +13 -0
  40. package/dist/git-representation.js +16 -0
  41. package/dist/inbox.d.ts +132 -0
  42. package/dist/inbox.js +81 -0
  43. package/dist/index.d.ts +31 -0
  44. package/dist/index.js +85 -0
  45. package/dist/internal/app-description.d.ts +14 -0
  46. package/dist/internal/app-description.js +98 -0
  47. package/dist/internal/app-release-artifact-reader.d.ts +26 -0
  48. package/dist/internal/app-release-artifact-reader.js +1 -0
  49. package/dist/internal/app-release-contract.d.ts +540 -0
  50. package/dist/internal/app-release-contract.js +169 -0
  51. package/dist/internal/app-release-schema.d.ts +1676 -0
  52. package/dist/internal/app-release-schema.js +386 -0
  53. package/dist/internal/app-release-store.d.ts +47 -0
  54. package/dist/internal/app-release-store.js +1 -0
  55. package/dist/internal/background-sync.d.ts +7 -0
  56. package/dist/internal/background-sync.js +10 -0
  57. package/dist/internal/deterministic-memory-app-release-store.d.ts +14 -0
  58. package/dist/internal/deterministic-memory-app-release-store.js +319 -0
  59. package/dist/internal/failure.d.ts +2 -0
  60. package/dist/internal/failure.js +9 -0
  61. package/dist/internal/immutable-release-material.d.ts +3 -0
  62. package/dist/internal/immutable-release-material.js +13 -0
  63. package/dist/internal/promise-app-release-store-adapter.d.ts +9 -0
  64. package/dist/internal/promise-app-release-store-adapter.js +65 -0
  65. package/dist/internal/promise-artifact-reader-adapter.d.ts +9 -0
  66. package/dist/internal/promise-artifact-reader-adapter.js +18 -0
  67. package/dist/internal/public-route-sql.d.ts +10 -0
  68. package/dist/internal/public-route-sql.js +239 -0
  69. package/dist/internal/public-website-authority.d.ts +338 -0
  70. package/dist/internal/public-website-authority.js +50 -0
  71. package/dist/internal/public-website-delivery.d.ts +59 -0
  72. package/dist/internal/public-website-delivery.js +232 -0
  73. package/dist/internal/public-website-fetch.d.ts +8 -0
  74. package/dist/internal/public-website-fetch.js +54 -0
  75. package/dist/internal/release-transition-material.d.ts +3 -0
  76. package/dist/internal/release-transition-material.js +11 -0
  77. package/dist/internal/verified-artifact-snapshot.d.ts +41 -0
  78. package/dist/internal/verified-artifact-snapshot.js +91 -0
  79. package/dist/public-route-moderation.d.ts +83 -0
  80. package/dist/public-route-moderation.js +236 -0
  81. package/dist/public-route-policy.d.ts +51 -0
  82. package/dist/public-route-policy.js +88 -0
  83. package/dist/public-routes.d.ts +34 -0
  84. package/dist/public-routes.js +136 -0
  85. package/dist/publisher.d.ts +61 -0
  86. package/dist/publisher.js +16 -0
  87. package/dist/safety-takedown.d.ts +56 -0
  88. package/dist/safety-takedown.js +146 -0
  89. package/dist/seller-closure-notice.d.ts +14 -0
  90. package/dist/seller-closure-notice.js +14 -0
  91. package/dist/seller-closure.d.ts +15 -0
  92. package/dist/seller-closure.js +31 -0
  93. package/dist/testing.d.ts +8 -0
  94. package/dist/testing.js +47 -0
  95. package/docs/api/README.md +26 -0
  96. package/docs/api/app-backend-binding-vocabulary.md +276 -0
  97. package/docs/api/app-backend-bindings.md +141 -0
  98. package/docs/api/app-event-facts.md +110 -0
  99. package/docs/api/app-method-context.md +166 -0
  100. package/docs/api/app-method-runtime.md +330 -0
  101. package/docs/api/backend.md +108 -0
  102. package/docs/api/background-replay.md +516 -0
  103. package/docs/api/cloudflare.md +1044 -0
  104. package/docs/api/effect.md +2429 -0
  105. package/docs/api/git-representation.md +56 -0
  106. package/docs/api/inbox.md +289 -0
  107. package/docs/api/index.md +2868 -0
  108. package/docs/api/publisher.md +147 -0
  109. package/docs/api/testing.md +31 -0
  110. package/package.json +158 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jamstack Consulting LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,444 @@
1
- # Temporary Holding Version
1
+ # Puffball (`@fungi.computer/puffball`)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Puffball is a catalog for web apps and their immutable releases. It records
4
+ which App exists, which Releases it has, and which Release each public route
5
+ serves. Before anything is served or selected, it reads the artifact bytes and
6
+ checks them against the digest the Release recorded.
7
+
8
+ Use it when you host apps for other people and need three guarantees: a Release
9
+ never changes after you create it, a route only serves a Release you explicitly
10
+ promoted, and a revoked Release stops serving right away. Team installation,
11
+ permissions, billing and execution stay with your host.
12
+
13
+ ## Install or set up
14
+
15
+ ```sh
16
+ npm install @fungi.computer/puffball
17
+ ```
18
+
19
+ The package is ESM. It depends on `effect` and on [Cairn](../cairn/README.md)
20
+ for transition IDs. The core runs anywhere with `Promise` and Web APIs. The
21
+ `./cloudflare` adapters run in Cloudflare Workers with D1 and R2 bindings.
22
+
23
+ You supply two things: a store for catalog records and an artifact reader that
24
+ turns an opaque artifact reference into verified bytes. The package ships an
25
+ in-memory store for tests and examples, and D1 and R2 adapters for Workers.
26
+
27
+ ## Method discovery
28
+
29
+ Methods may declare `hidden: true` to omit maintenance operations from Workflow
30
+ and Agent/MCP discovery. The marker is preserved in the digest-covered App API;
31
+ it grants or denies no direct-call authority. Unmarked methods remain visible.
32
+
33
+ An API can opt into a dynamic tool namespace with
34
+ `toolFamily: { name: "flows", list: "listWorkflowTools", call: "runWorkflow" }`.
35
+ The descriptor is signed with the Release. Its listing must be a declared read
36
+ method, its call must be discoverable and non-external, and its namespace must
37
+ not overlap static methods. Omitting it preserves ordinary API bytes. Inventory
38
+ and schema metadata grant no invocation authority.
39
+
40
+ ## App event declarations
41
+
42
+ An App API may include `events: Record<string, { description, payload }>` beside
43
+ `methods`. `payload` is a bounded JSON Schema projection for the whole emitted
44
+ payload. Agar generates it from the backend's Zod event schema. Puffball's
45
+ `appApiSchema` preserves it through the App declaration and immutable Release
46
+ codec, so changes to event metadata change the Release bytes and digest. An API
47
+ without events omits the field. Event declarations grant no authority; the host
48
+ owns installed-Release selection, emission admission and routing.
49
+
50
+ Emitting Apps request the `app.events.emit` capability. The maintained
51
+ `EVENTS.emit` operation requires that capability, approved explicitly per
52
+ installation through the host's existing capability grant. Declaring an event or
53
+ binding does not approve emission.
54
+
55
+ The catalog accepts at most 64 events, names of 1–128 characters, descriptions
56
+ of 1–1000 characters, and the same 20,000-byte/depth-16 projection and
57
+ 256,000-byte total API bounds as method metadata. These are declaration-read
58
+ bounds, not event-delivery or retention limits.
59
+
60
+ ## Host attribution for App calls
61
+
62
+ The runtime-neutral `./app-method-runtime` entry owns `HostActor`,
63
+ `WorkflowAttestation`, `AppMethodCallContext` and their maintained parsers.
64
+ Actors are members, Agents or Team keys identified by their IDs. The host may
65
+ attach a role (`owner` or `member`) and a Workflow attestation containing
66
+ `workflowId`, `runId`, `nodeId` and the typed `armedBy` actor. Ordinary calls
67
+ omit the Workflow field. The Effect caller decoder delegates to this same
68
+ grammar.
69
+
70
+ These facts come from host admission, separately from App method input. Decoding
71
+ validates their representation; it does not grant authority or verify that a run
72
+ is active. Invocation and Git owners re-admit the stored actor and run before
73
+ effects.
74
+
75
+ ## Native SQLite test adapter
76
+
77
+ Use the public testing entry with a caller-owned Node SQLite database:
78
+
79
+ ```ts
80
+ import { DatabaseSync } from "node:sqlite";
81
+ import { APP_RELEASE_D1_FRESH_SCHEMA } from "@fungi.computer/puffball/cloudflare";
82
+ import { sqliteD1 } from "@fungi.computer/puffball/testing";
83
+
84
+ const sqlite = new DatabaseSync(":memory:");
85
+ sqlite.exec("PRAGMA foreign_keys = ON");
86
+ sqlite.exec(APP_RELEASE_D1_FRESH_SCHEMA);
87
+ const database = sqliteD1(sqlite);
88
+ const apps = await database
89
+ .prepare("SELECT app_id FROM app_release_apps")
90
+ .all();
91
+ console.log(apps.results); // []
92
+ sqlite.close();
93
+ ```
94
+
95
+ The adapter uses the public Watchdog Node SQLite owner. It supplies prepared
96
+ statements and atomic batches for tests; it is not a Cloudflare emulator or
97
+ isolation boundary. The caller owns schema setup and database lifetime.
98
+
99
+ ## Quick start
100
+
101
+ This creates an App, a public route, and one Release whose artifact is a small
102
+ HTML page, then promotes the Release and reads the page back through the route.
103
+ It runs in Node with the in-memory store and a ten-line artifact reader:
104
+
105
+ ```ts
106
+ import { createHash } from "node:crypto";
107
+ import {
108
+ createAppReleaseCatalog,
109
+ parseAppId,
110
+ parseArtifactRef,
111
+ parseIdempotencyKey,
112
+ parsePublicSurfaceId,
113
+ parseReleaseId,
114
+ parseSha256Digest,
115
+ parseSourceRevision,
116
+ parseStableRoute,
117
+ type ArtifactMediaType,
118
+ type ArtifactReader,
119
+ type ArtifactRef,
120
+ } from "@fungi.computer/puffball";
121
+ import { createDeterministicMemoryAppReleaseStore } from "@fungi.computer/puffball/effect";
122
+
123
+ const sha256 = (bytes: Uint8Array) =>
124
+ parseSha256Digest(
125
+ `sha256:${createHash("sha256").update(bytes).digest("hex")}`,
126
+ );
127
+
128
+ // Two artifacts: the HTML page, and the App root that points at it.
129
+ const html = new TextEncoder().encode("<!doctype html><h1>Hello</h1>");
130
+ const htmlRef = parseArtifactRef("artifact:hello.ui");
131
+ const root = new TextEncoder().encode(
132
+ JSON.stringify({
133
+ format: "fungi-app-v1",
134
+ ui: {
135
+ artifactRef: htmlRef,
136
+ digest: sha256(html),
137
+ version: "v1",
138
+ byteLength: html.byteLength,
139
+ mediaType: "text/html; charset=utf-8",
140
+ },
141
+ }),
142
+ );
143
+ const rootRef = parseArtifactRef("artifact:hello.release");
144
+
145
+ // A tiny artifact reader: look the bytes up, hash them, compare digests.
146
+ const files = new Map<
147
+ ArtifactRef,
148
+ { bytes: Uint8Array; mediaType: ArtifactMediaType }
149
+ >([
150
+ [htmlRef, { bytes: html, mediaType: "text/html; charset=utf-8" }],
151
+ [rootRef, { bytes: root, mediaType: "application/vnd.fungi.app+json" }],
152
+ ]);
153
+ const artifactReader: ArtifactReader = {
154
+ read: async ({ artifactRef, expectedDigest }) => {
155
+ const file = files.get(artifactRef);
156
+ if (file === undefined) return { kind: "missing" };
157
+ const digest = sha256(file.bytes);
158
+ if (digest !== expectedDigest)
159
+ return { kind: "digest-mismatch", actualDigest: digest };
160
+ return {
161
+ kind: "verified",
162
+ digest,
163
+ mediaType: file.mediaType,
164
+ byteLength: file.bytes.byteLength,
165
+ bytes: {
166
+ byteLength: file.bytes.byteLength,
167
+ copy: () => file.bytes.slice(),
168
+ },
169
+ };
170
+ },
171
+ };
172
+
173
+ const catalog = createAppReleaseCatalog({
174
+ artifactReader,
175
+ store: createDeterministicMemoryAppReleaseStore(),
176
+ });
177
+
178
+ const appId = parseAppId("app_hello");
179
+ const publicSurfaceId = parsePublicSurfaceId("ps_hello");
180
+ const releaseId = parseReleaseId("rel_hello1");
181
+ const route = parseStableRoute("/hello");
182
+
183
+ await catalog.createApp({ appId });
184
+ await catalog.createPublicSurface({
185
+ publicSurfaceId,
186
+ appId,
187
+ stableRoute: route,
188
+ publisher: {
189
+ kind: "platform",
190
+ teamId: "team_hello",
191
+ userId: "user_hello",
192
+ requestKey: publicSurfaceId,
193
+ },
194
+ });
195
+ await catalog.createRelease({
196
+ releaseId,
197
+ appId,
198
+ artifact: {
199
+ artifactRef: rootRef,
200
+ artifactDigest: sha256(root),
201
+ provenance: {
202
+ sourceRevision: parseSourceRevision(`git:${"a".repeat(40)}`),
203
+ sourceTreeDigest: sha256(new TextEncoder().encode("source tree")),
204
+ buildRecipeDigest: sha256(new TextEncoder().encode("build recipe")),
205
+ },
206
+ },
207
+ });
208
+ await catalog.verifyReleaseArtifact(releaseId);
209
+ await catalog.transition({
210
+ kind: "promotion",
211
+ publicSurfaceId,
212
+ releaseId,
213
+ idempotencyKey: parseIdempotencyKey("idem_hello-promote-1"),
214
+ });
215
+
216
+ const website = await catalog.resolvePublicWebsite(route);
217
+ console.log(new TextDecoder().decode(website.bytes.copy()));
218
+ // <!doctype html><h1>Hello</h1>
219
+ ```
220
+
221
+ ## Core concepts
222
+
223
+ ### Records
224
+
225
+ | Record | Owns |
226
+ | ------------------ | --------------------------------------------------------------------- |
227
+ | App | Stable global identity registered in the catalog. |
228
+ | Release | Immutable App artifact reference, digest and source/build provenance. |
229
+ | Public Surface | An App-owned stable route with an explicit selected Release. |
230
+ | Transition receipt | The recorded result of an operation-scoped selection or revocation. |
231
+
232
+ IDs describe identity. They do not grant permission to change that identity.
233
+ Each ID has a fixed grammar, such as `app_<a-z0-9>`, `rel_<a-z0-9>`,
234
+ `ps_<a-z0-9>`, `artifact:<name>`, `idem_<key>`, `git:<40 hex>`,
235
+ `sha256:<64 hex>` and a single-segment route like `/hello`. Parse untrusted
236
+ strings with the root `parse*` functions. A rejected value throws
237
+ `AppReleaseInputError` naming the parser.
238
+
239
+ ### Entrypoints
240
+
241
+ The root entrypoint exports `createAppReleaseCatalog`, the `parse*` boundary
242
+ parsers, the record types, the error classes and the App declaration vocabulary.
243
+ The catalog is a Promise facade over an Effect core. The `./effect` entrypoint
244
+ exposes that core (`createAppReleaseCore`), the `decode*` functions and the
245
+ in-memory store. Both use the same grammar and decisions.
246
+
247
+ ### App roots
248
+
249
+ A Release artifact is an App root with media type
250
+ `application/vnd.fungi.app+json` and `format: "fungi-app-v1"`. It binds a visual
251
+ part (an HTML page or an asset manifest), an optional durable backend artifact,
252
+ or both, under one digest-covered description. An App can be visual, headless or
253
+ both, but never empty. `./app` builds and parses App roots, `./backend` parses
254
+ backend descriptors, and `./assets` parses asset manifests.
255
+
256
+ Declared capabilities, handlers, roles and commands describe what an App may ask
257
+ its host to expose. They do not approve those operations.
258
+
259
+ ### App storage declarations
260
+
261
+ An App can include bounded keys for its own data in `fungi.app.storage`:
262
+
263
+ ```json
264
+ [{ "key": "desktop", "scopes": ["team", "member"], "maxBytes": 16384 }]
265
+ ```
266
+
267
+ Each digest-covered declaration allows at most 16 distinct keys. A key matches
268
+ `[a-z][a-z0-9.-]{0,63}`; its non-empty scopes are a unique subset of `team` and
269
+ `member`, and `maxBytes` is from 1 through 65,536. This requests storage for the
270
+ App's own installation data. It grants no access to another App, installation or
271
+ Team; the Hub owns scoped admission and write authority.
272
+
273
+ ### Artifact readers
274
+
275
+ The artifact reader resolves a stored opaque reference and verifies it.
276
+ Verification checks the expected digest, content type, configured size bounds
277
+ and an owned copy of the bytes. A reader never uses a caller-supplied digest to
278
+ find different bytes. Asset manifests also bind object versions, digests,
279
+ lengths and a mount base. Your host keeps admission and document isolation.
280
+
281
+ ### Create, verify and select
282
+
283
+ Create the App before its Releases and Surfaces. A Release or Surface for a
284
+ missing App throws `AppReleaseOwnerAppAbsentError` or
285
+ `AppReleaseSurfaceOwnerAppAbsentError`. Replaying the same immutable record
286
+ returns it. Reusing an identity with changed material fails, and for a Release
287
+ that is `AppReleaseImmutableConflictError`.
288
+
289
+ Creating a Release does not promote it. Verify its artifact, then request an
290
+ explicit `promotion` or `rollback` to an eligible Release that belongs to the
291
+ App. Unverified, revoked and cross-App selections fail. Retrying a transition
292
+ with the same idempotency key and input returns its receipt. Changed input under
293
+ that key throws `AppReleaseTransitionIdempotencyConflictError` and leaves the
294
+ selection alone.
295
+
296
+ A `revocation` stops eligible delivery and selection. It does not edit immutable
297
+ artifact bytes or decide how a Team restores an installation. Your host's
298
+ backend state lifecycle and schema migrations are separate from Release facts.
299
+
300
+ ### Serving selected bytes
301
+
302
+ `resolvePublicWebsite(route)` takes a canonical stable route, reads the current
303
+ selection, verifies the artifact and rechecks authority before returning bytes.
304
+ A private cache never skips that recheck. Request bodies, cookies and
305
+ caller-supplied Release IDs cannot select unpublished content.
306
+
307
+ ### Cloudflare adapters
308
+
309
+ `./cloudflare` provides `createD1AppReleaseStore` for catalog state,
310
+ `createR2ArtifactReader` for artifact reads, and `APP_RELEASE_D1_FRESH_SCHEMA`
311
+ for an empty database. `./cloudflare-assets` reads and verifies asset builds
312
+ from R2. `./publisher` holds the schemas for registering an App's source
313
+ repository with a publishing Team. The adapters do not provision resources or
314
+ supply Team authorization.
315
+
316
+ ## API reference
317
+
318
+ See the generated [API reference](docs/api/README.md). The entrypoints are
319
+ `@fungi.computer/puffball` and its `./effect`, `./app`, `./backend`, `./assets`,
320
+ `./cloudflare`, `./cloudflare-assets` and `./publisher` subpaths.
321
+
322
+ ## Examples and checks
323
+
324
+ ### Revoke a Release
325
+
326
+ Continuing the quick start, revoke the Release. The route stops serving it and
327
+ it can no longer be promoted:
328
+
329
+ ```ts
330
+ await catalog.transition({
331
+ kind: "revocation",
332
+ releaseId,
333
+ idempotencyKey: parseIdempotencyKey("idem_hello-revoke-1"),
334
+ });
335
+
336
+ await catalog.resolvePublicWebsite(route);
337
+ // throws AppReleasePublicWebsiteUnavailableError
338
+
339
+ await catalog.transition({
340
+ kind: "promotion",
341
+ publicSurfaceId,
342
+ releaseId,
343
+ idempotencyKey: parseIdempotencyKey("idem_hello-promote-2"),
344
+ });
345
+ // throws AppReleaseTransitionReleaseRevokedError
346
+ ```
347
+
348
+ A route with no Surface throws `AppReleasePublicWebsiteNotFoundError`.
349
+
350
+ ### Run on Workers
351
+
352
+ ```ts
353
+ import { createAppReleaseCatalog } from "@fungi.computer/puffball";
354
+ import {
355
+ createD1AppReleaseStore,
356
+ createR2ArtifactReader,
357
+ type AppReleaseD1Database,
358
+ type AppReleaseR2Bucket,
359
+ } from "@fungi.computer/puffball/cloudflare";
360
+
361
+ type Env = { DB: AppReleaseD1Database; ARTIFACTS: AppReleaseR2Bucket };
362
+
363
+ export function catalogFor(env: Env) {
364
+ return createAppReleaseCatalog({
365
+ store: createD1AppReleaseStore(env.DB),
366
+ artifactReader: createR2ArtifactReader(env.ARTIFACTS),
367
+ });
368
+ }
369
+ ```
370
+
371
+ The R2 reader uses the artifact reference as the object key and the object's
372
+ `contentType` as its media type.
373
+
374
+ ### Local website demo
375
+
376
+ From a checkout, a self-checking demo serves a fixture App over loopback HTTP,
377
+ checks that cold and warm reads return the same bytes, then revokes the Release
378
+ and checks that it stops serving. It needs no provider credentials:
379
+
380
+ ```sh
381
+ pnpm --filter @fungi.computer/puffball demo:a1.2:self-check
382
+ ```
383
+
384
+ `demo:a1.2` runs it interactively: open the printed `/orchid` URL, press `r` to
385
+ revoke and `q` or Ctrl-C to quit. The demo's Fetch adapter serves the canonical
386
+ GET path, returns 404 for malformed or unselected routes, 503 for unsafe
387
+ delivery and 405 for other methods. Those statuses belong to the demo, not to
388
+ every host's HTTP contract.
389
+
390
+ The test suite covers create and replay, transitions, idempotency conflicts and
391
+ public website delivery, plus native Cloudflare proofs for reconstruction,
392
+ storage and receipt conflicts, and lifecycle races:
393
+
394
+ ```sh
395
+ pnpm --filter @fungi.computer/puffball test
396
+ ```
397
+
398
+ ## Limits and errors
399
+
400
+ - The default artifact policy accepts artifacts up to 65,536 bytes. Pass
401
+ `artifactPolicy: { maxBytes, maxManifestBytes }` to `createAppReleaseCatalog`
402
+ to change it.
403
+ - Every failure is a named error class exported from the root, such as
404
+ `AppReleaseInputError`, `AppReleaseImmutableConflictError`,
405
+ `AppReleaseArtifactDigestMismatchError`,
406
+ `AppReleaseTransitionReleaseUnverifiedError`,
407
+ `AppReleaseTransitionIdempotencyConflictError`,
408
+ `AppReleasePublicWebsiteNotFoundError` and
409
+ `AppReleasePublicWebsiteUnavailableError`.
410
+ - A store that returns a malformed result causes
411
+ `AppReleaseInvalidStoreResultError`. An unreachable store causes
412
+ `AppReleaseStoreUnavailableError`.
413
+ - The catalog does not deploy, configure domains, build from source or publish.
414
+ Those stay with your host.
415
+
416
+ ## Contribute and license
417
+
418
+ MIT. See [LICENSE](LICENSE).
419
+
420
+ `@fungi.computer/puffball/app-event-facts` owns the canonical App EventSink
421
+ input and saved host-fact decoders. `git-representation` owns repository-name,
422
+ SHA-1, ref-update and push-event-id schemas shared with Agar. These decode
423
+ representations; they do not admit callers or grant Git access.
424
+
425
+ Background work uses `@fungi.computer/puffball/background-replay`. A host calls
426
+ `backgroundContext` with the verified method name/effect, actual invocation
427
+ ancestry and a host-sealed `backgroundOrigin`. An App checks
428
+ `mayEnqueueBackground` on that host-supplied context and retains the origin with
429
+ the accepted job; a replay never gets enqueue eligibility. Agents, keys,
430
+ Workflow nodes and nested calls keep their work in the foreground. A wake
431
+ retains only the member identity and observed revisions. `readmitBackground`
432
+ resolves the member and re-admits the declared `backend.sync.method` on every
433
+ delivery. That ordinary write method must cover every write method's capability
434
+ requirements. An unavailable installation is a host dead letter; a
435
+ still-available refused lane may receive `syncRefused` with no binding grants.
436
+
437
+ The host derives its non-extractable signing key with `backgroundOriginKey` and
438
+ seals the Instance, member lane and verified foreground method with
439
+ `sealBackgroundOrigin`. In a replay, `BACKGROUND.admit` checks the claimed job's
440
+ retained origin against freshly admitted methods. Each frame permits one check:
441
+ other capability effects wait for its admitted decision, while `APP_STORAGE`
442
+ remains available for claiming and settling. A refused claim returns
443
+ `{kind: "refused", code: "access_changed"}` before those effects; the App cannot
444
+ declare the host-only `BACKGROUND` binding.
@@ -0,0 +1,7 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { type AppApi } from "./app-vocabulary.js";
3
+ export { appApiReservedMethodNames } from "./app-method-runtime.js";
4
+ /** Public method names, including grouped operations such as desktop.arrange. A name cannot also be a group (`files` with `files.read`). */
5
+ export declare const appApiMethodNameSchema: Schema.String;
6
+ /** The build-generated, digest-covered method and event catalog; it grants no authority. */
7
+ export declare const appApiSchema: Schema.Codec<AppApi>;
@@ -0,0 +1,61 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { appCapabilityIds } from "./app-vocabulary.js";
3
+ import { isAppApiMethodName } from "./app-method-runtime.js";
4
+ export { appApiReservedMethodNames } from "./app-method-runtime.js";
5
+ /** Public method names, including grouped operations such as desktop.arrange. A name cannot also be a group (`files` with `files.read`). */
6
+ export const appApiMethodNameSchema = Schema.String.check(Schema.makeFilter(isAppApiMethodName));
7
+ function withinDepth(value, depth = 0) {
8
+ if (depth > 16)
9
+ return false;
10
+ if (Schema.is(Schema.Array(Schema.Json))(value))
11
+ return value.every((child) => withinDepth(child, depth + 1));
12
+ if (Schema.is(Schema.JsonObject)(value))
13
+ return Object.values(value).every((child) => withinDepth(child, depth + 1));
14
+ return true;
15
+ }
16
+ /** JSON Schema projections are data, bounded before a renderer or compiler reads them. */
17
+ const projection = Schema.JsonObject.pipe(Schema.check(Schema.makeFilter((value) => new TextEncoder().encode(JSON.stringify(value)).byteLength <= 20_000 &&
18
+ withinDepth(value), { message: "App API schema exceeds its byte or depth bound" })));
19
+ const eventName = Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(128));
20
+ const method = Schema.Struct({
21
+ description: Schema.String.pipe(Schema.check(Schema.isMinLength(1)), Schema.check(Schema.isMaxLength(1000))),
22
+ input: projection,
23
+ output: projection,
24
+ pending: Schema.optional(Schema.Literal(true)),
25
+ hidden: Schema.optional(Schema.Literal(true)),
26
+ anonymous: Schema.optional(Schema.Literal(true)),
27
+ effect: Schema.Literals(["read", "write", "external"]),
28
+ requires: Schema.Array(Schema.Literals(appCapabilityIds)).pipe(Schema.check(Schema.makeFilter((values) => new Set(values).size === values.length))),
29
+ }).check(Schema.makeFilter((value) => value.anonymous !== true ||
30
+ (value.effect === "read" && value.requires.length === 0), {
31
+ message: "Anonymous methods must be reads with no required capabilities",
32
+ }));
33
+ /** The build-generated, digest-covered method and event catalog; it grants no authority. */
34
+ export const appApiSchema = Schema.Struct({
35
+ methods: Schema.Record(Schema.String, method).pipe(Schema.check(Schema.makeFilter((methods) => Object.keys(methods).every(Schema.is(appApiMethodNameSchema)), { message: "Invalid App API method name" })), Schema.check(Schema.makeFilter((methods) => Object.keys(methods).length <= 64)), Schema.check(Schema.makeFilter((methods) => {
36
+ const names = Object.keys(methods);
37
+ return names.every((name) => !names.some((other) => other.startsWith(`${name}.`)));
38
+ }, { message: "App API method name is also a method group" }))),
39
+ toolFamily: Schema.optionalKey(Schema.Struct({
40
+ name: appApiMethodNameSchema.check(Schema.makeFilter((name) => !name.includes("."))),
41
+ list: appApiMethodNameSchema,
42
+ call: appApiMethodNameSchema,
43
+ })),
44
+ events: Schema.optionalKey(Schema.Record(Schema.String, Schema.Struct({
45
+ description: Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(1000)),
46
+ payload: projection,
47
+ })).check(Schema.makeFilter((events) => Object.keys(events).every(Schema.is(eventName)), { message: "Invalid App API event name" }), Schema.makeFilter((events) => Object.keys(events).length <= 64))),
48
+ }).pipe(Schema.check(Schema.makeFilter((api) => {
49
+ if (api.toolFamily === undefined)
50
+ return true;
51
+ const { name, list, call } = api.toolFamily;
52
+ const listing = api.methods[list];
53
+ const operation = api.methods[call];
54
+ return (listing?.effect === "read" &&
55
+ operation !== undefined &&
56
+ operation.effect !== "external" &&
57
+ operation.hidden !== true &&
58
+ !Object.keys(api.methods).some((method) => method.split(".")[0] === name));
59
+ }, {
60
+ message: "Tool family must reference declared callable methods in a distinct namespace",
61
+ })), Schema.check(Schema.makeFilter((api) => new TextEncoder().encode(JSON.stringify(api)).byteLength <= 256_000, { message: "App API exceeds its total byte bound" })));