@fabrika/app 0.9.0-cloudflare.1 → 0.9.0-cloudflare.2

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 (41) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +4 -2
  3. package/skill/references/app-setup.md +29 -11
  4. package/skill/references/endpoint.md +5 -0
  5. package/skill/references/errors.md +31 -31
  6. package/skill/references/jobs.md +9 -0
  7. package/skill/references/tests.md +22 -1
  8. package/src/build.ts +6 -1
  9. package/src/bun.ts +10 -0
  10. package/src/cloudflare/application.ts +3 -3
  11. package/src/cloudflare/bindings.ts +26 -5
  12. package/src/cloudflare/names.ts +6 -0
  13. package/src/contract/artifact-schema.ts +6 -1
  14. package/src/contract/build.ts +33 -10
  15. package/src/contract/group.ts +10 -1
  16. package/src/contract/kit.ts +2 -1
  17. package/src/disk-assets.ts +8 -3
  18. package/src/implement/boot.ts +9 -3
  19. package/src/implement/capability.ts +3 -0
  20. package/src/implement/dispatch.ts +4 -4
  21. package/src/implement/egress.ts +341 -8
  22. package/src/implement/invocation.ts +10 -9
  23. package/src/jobs/runner.ts +7 -2
  24. package/src/spa-fallback.ts +12 -0
  25. package/src/testing/endpoint-access.ts +135 -23
  26. package/src/testing/isolation.ts +8 -1
  27. package/types/src/build.d.ts +1 -1
  28. package/types/src/cloudflare/bindings.d.ts +6 -0
  29. package/types/src/cloudflare/names.d.ts +5 -0
  30. package/types/src/contract/group.d.ts +9 -1
  31. package/types/src/contract/kit.d.ts +2 -2
  32. package/types/src/disk-assets.d.ts +4 -1
  33. package/types/src/implement/boot.d.ts +6 -0
  34. package/types/src/implement/capability.d.ts +2 -0
  35. package/types/src/implement/dispatch.d.ts +2 -1
  36. package/types/src/implement/egress.d.ts +36 -1
  37. package/types/src/implement/invocation.d.ts +3 -3
  38. package/types/src/jobs/runner.d.ts +3 -0
  39. package/types/src/spa-fallback.d.ts +2 -0
  40. package/types/src/testing/endpoint-access.d.ts +25 -1
  41. package/types/src/testing/isolation.d.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.9.0-cloudflare.1",
3
+ "version": "0.9.0-cloudflare.2",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -109,14 +109,14 @@
109
109
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
110
110
  },
111
111
  "dependencies": {
112
- "@fabrika/auth": "0.9.0-cloudflare.1",
113
- "@fabrika/auth-core": "0.9.0-cloudflare.1",
114
- "@fabrika/db": "0.9.0-cloudflare.1",
115
- "@fabrika/email": "0.9.0-cloudflare.1",
116
- "@fabrika/jwks": "0.9.0-cloudflare.1",
117
- "@fabrika/platform": "0.9.0-cloudflare.1",
118
- "@fabrika/platform-node": "0.9.0-cloudflare.1",
119
- "@fabrika/proxy-core": "0.9.0-cloudflare.1",
112
+ "@fabrika/auth": "0.9.0-cloudflare.2",
113
+ "@fabrika/auth-core": "0.9.0-cloudflare.2",
114
+ "@fabrika/db": "0.9.0-cloudflare.2",
115
+ "@fabrika/email": "0.9.0-cloudflare.2",
116
+ "@fabrika/jwks": "0.9.0-cloudflare.2",
117
+ "@fabrika/platform": "0.9.0-cloudflare.2",
118
+ "@fabrika/platform-node": "0.9.0-cloudflare.2",
119
+ "@fabrika/proxy-core": "0.9.0-cloudflare.2",
120
120
  "altcha-lib": "2.6.0",
121
121
  "jose": "^5.9.0"
122
122
  },
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.9.0-cloudflare.1
3
+ version: 0.9.0-cloudflare.2
4
4
  description: >
5
5
  How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
6
  slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
@@ -231,6 +231,7 @@ the access check. `references/spa.md` shows the stack.
231
231
  | e-mail (a cron job only) | `mailer: 'installation'` | `ctx.mail.send` |
232
232
  | a file from a public procedure | `file('<kind>')` input, `admission` with `bytesPerHour` | a reference the handler writes into a file column |
233
233
  | an outside HTTPS API | `egress: ['https://api.example.com']` | `fetch` to exactly those origins |
234
+ | any public website (a job) | `egress: 'public'` | `fetch` to public hosts, never the installation |
234
235
  | a signed link (unsubscribe) | `signs: ['<purpose>']` | `ctx.tokens.sign` |
235
236
  | a webhook binding | `binds: '<webhook>'` | `ctx.webhooks('<webhook>').bind` and `.unbind` |
236
237
  | a job run after the commit | `kicks: ['<job>']` | `ctx.kick('<job>')` |
@@ -300,7 +301,8 @@ one Durable Object over SQLite. Its `db/migrations/*.sql` are SQLite (`reference
300
301
  object applies them when it boots, so there is no `migrate` and no command needs a database URL or `FABRIKA_APP_ID`.
301
302
  The tests run on `@fabrika/db/testing-sqlite` (`references/tests.md`), in memory, and never skip. A handler holds the
302
303
  object's transaction, so its `limits.timeoutMs` is at most 25 s. No `sql` (`ctx.sql`) and no `scan: true`: the
303
- checker, the deploy and the boot refuse both.
304
+ checker, the deploy and the boot refuse both. The deploy serves each SPA mount's built `dir` itself; declare no
305
+ `assets` on the Worker (`references/app-setup.md`).
304
306
 
305
307
  The object runs one transaction at a time, so every `await` in a handler that may write holds the whole environment:
306
308
  IAM, R2, a `fetch`, a `ctx.connect` call, a binding. One 2 s call caps the environment at half a request a second. A
@@ -19,20 +19,20 @@ the SPA is added with it.
19
19
  "test": "bun test --isolate"
20
20
  },
21
21
  "dependencies": {
22
- "@fabrika/app": "0.9.0-cloudflare.1",
23
- "@fabrika/db": "0.9.0-cloudflare.1",
24
- "@fabrika/provider-zerops": "0.9.0-cloudflare.1",
25
- "@fabrika/proxy-core": "0.9.0-cloudflare.1",
26
- "@fabrika/ui": "0.9.0-cloudflare.1",
22
+ "@fabrika/app": "0.9.0-cloudflare.2",
23
+ "@fabrika/db": "0.9.0-cloudflare.2",
24
+ "@fabrika/provider-zerops": "0.9.0-cloudflare.2",
25
+ "@fabrika/proxy-core": "0.9.0-cloudflare.2",
26
+ "@fabrika/ui": "0.9.0-cloudflare.2",
27
27
  "kysely": "0.29.6",
28
28
  "react": "^19.0.0",
29
29
  "react-dom": "^19.0.0",
30
30
  "zod": "^3.25.76"
31
31
  },
32
32
  "devDependencies": {
33
- "@fabrika/check": "0.9.0-cloudflare.1",
34
- "@fabrika/cli": "0.9.0-cloudflare.1",
35
- "@fabrika/platform": "0.9.0-cloudflare.1",
33
+ "@fabrika/check": "0.9.0-cloudflare.2",
34
+ "@fabrika/cli": "0.9.0-cloudflare.2",
35
+ "@fabrika/platform": "0.9.0-cloudflare.2",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "^1.4.3",
38
38
  "@types/react": "^19.0.0",
@@ -249,6 +249,22 @@ The platform's build runs the tests, writes the entrypoint, runs `fabrika-check`
249
249
  run `fabrika db migrate`, `fabrika db codegen --verify` and `fabrika db test-isolation` before the server starts: a
250
250
  version does not go live until every generated test passes. It writes every `FABRIKA_*` variable; never set one.
251
251
 
252
+ ## The deploy surface (Cloudflare)
253
+
254
+ On Cloudflare `fabrika.config.ts` imports `defineApp`, `Worker` and `iamServiceReference` from
255
+ `@fabrika/provider-cloudflare`, and `resources` returns the application Worker: `main: './fabrika/entry.generated.ts'`,
256
+ `workers_dev: false`, `preview_urls: false`, `routes: []` and `bindings: { IAM: iamServiceReference() }`, with
257
+ `pipeline: { workerDir: '.', build: 'bun run build:web' }` when the application has an SPA. The deploy adds the
258
+ Durable Object, the files bucket, the minute tick, every `FABRIKA_*` variable and the proxy Worker.
259
+
260
+ It also serves the SPA: when the Worker declares no `assets`, the deploy copies the build directory of each SPA and
261
+ link mount (`dir`, a link's `spa`) into `fabrika/assets/` at the mount's prefix (`/admin` into `fabrika/assets/admin/`)
262
+ after `pipeline.build`, and binds it as `ASSETS`. A deploy whose mount directory the build did not write fails and
263
+ names the mount. Do not declare `assets` yourself. A Worker that declares them keeps them, and the object reads each
264
+ request path from that directory as it is (`/admin/app.js` is `admin/app.js` there), so a hand-written `assets` works
265
+ only for a root mount, or for mounts whose directories nest like their prefixes; it needs `binding: 'ASSETS'` and
266
+ `run_worker_first: true`.
267
+
252
268
  ## The SPA (added when the application has one)
253
269
 
254
270
  The SPA is React, built from the screens and hooks of the framework: `@fabrika/app/react` runs the typed client's
@@ -379,8 +395,9 @@ await Bun.write(
379
395
  `src/schemas.ts`, `src/shared/` and its own modules; never a slice, `src/db/` or a Bun or Node module. The plugin loads the
380
396
  contract without its handlers. A row type the screen declares (`Task` above) is checked against the answer where
381
397
  the rows are passed in.
382
- - The last step points the page's assets at the SPA's prefix (`/`): the mount answers `index.html` for every path
383
- under it, so a deep link such as `/tasks/<id>` renders. `define` gives React's production build.
398
+ - The last step points the page's assets at the SPA's prefix (`/`): the mount answers `index.html` for every page
399
+ route under it (a path without an extension, or a navigation), so a deep link such as `/tasks/<id>` renders; a
400
+ missing asset such as an old `chunk-….js` is 404. `define` gives React's production build.
384
401
  - `sourcemap: 'external'` writes each script's map beside it, and the page names none. The platform build uploads the
385
402
  maps to Operations for the release the page reports, so an error's frames show the original source; the mount
386
403
  never serves a `.map` file (404). Keep the maps external: `inline` would ship the source to every visitor.
@@ -397,4 +414,5 @@ await Bun.write(
397
414
 
398
415
  ## Ignored files
399
416
 
400
- `.gitignore`: `node_modules/`, `fabrika.manifest.json`, `/fabrika/entry.generated.ts`, `/dist/`.
417
+ `.gitignore`: `node_modules/`, `fabrika.manifest.json`, `/fabrika/entry.generated.ts`, `/dist/`, and on Cloudflare
418
+ `/fabrika/assets/`, where a deploy stages the SPA's files.
@@ -656,6 +656,11 @@ On Cloudflare a procedure waits for its outside call inside the object's transac
656
656
  with it (`SKILL.md`). A mutation records a row and kicks a job, which declares the `egress` and calls outside
657
657
  `ctx.transaction` (`references/jobs.md`).
658
658
 
659
+ A cron job that calls hosts nobody knows when the contract is written (the websites its operators enter: a crawler, an
660
+ auditor, a link checker) declares `egress: 'public'` instead of a list (`references/jobs.md`). Only a cron job may: a
661
+ procedure, a mutation and a webhook name their origins. Never keep `fetch` from before boot or open sockets to get past
662
+ the list; that is hidden reach.
663
+
659
664
  ## Links to your own procedures: `ctx.url`
660
665
 
661
666
  `ctx.url('<endpoint id>', { input?, query? })` builds an absolute URL of one of the application's own procedures, on
@@ -8,32 +8,32 @@ architecture, and a refusal's allowed form is the one fix that passes. There is
8
8
 
9
9
  The refusals agents meet most, and their fix:
10
10
 
11
- | Rule | Means | Fix |
12
- | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
13
- | `literal` | the shell (contract, vocabulary, tables, groups, schemas) holds a computed value or call | literal objects, `z.*` calls and imported literal consts; move logic into `.handle(…)` |
14
- | `contract` | the contract does not compile (a reserved role, an unknown action, a wrong field) | the message names the field |
15
- | `package-replacement` | `package.json` names the app under `@fabrika/*`, or a dependency is `workspace:*` | another name; a registry range such as `^0.0.4` |
16
- | `generated-test-hash` | a generated test is missing, edited or stale | `bunx fabrika db test-scaffold`, and commit what it writes |
17
- | `endpoint-fixture` | an endpoint's input cannot be sampled, a fixture or seed names no endpoint, or a member, invitation or link field no endpoint that reads, cancels or revokes one | a literal in `src/__tests__/endpoint-access.fixtures.ts` (`references/tests.md`) |
18
- | `raw-sql` | a driver's `sql` (imported from `bun`, Kysely, `postgres`) | the handles, or `sql: 'read'` and `ctx.sql` |
19
- | `forbidden-import` | a driver, an S3 client, `jose`, `node:http`, `child_process` | the handles, a file column with `ctx.files`, a connector |
20
- | `forbidden-global` | `Bun.sql`, `Bun.serve`, `Bun.spawn`; `globalThis`, `window`, `self`, `global` or `Bun` in a server module | the handles; state lives in a table, not in a global; `crypto.randomUUID()` for an id |
21
- | `env-read` | `process.env`, `Bun.env` | `defineDeps(build)`, which receives the application's variables |
22
- | `client-identity` | an input field `userId`, `ownerId`, `createdBy`, `tenantId`, `workspaceId` | the access form's `from`, `ctx.principal.id`, or `createdBy` in the policy |
23
- | `type-assertion` | `as T` (other than `as const`), `<T>x` | fix the type; `fromRow` parses a row into a schema's type |
24
- | `sensitive-log` | a log of an error object, a request, headers or the environment | log a short fixed message |
25
- | `migration-name` | a migration not named `YYYYMMDDHHMMSS_slug.sql` (UTC) | `bunx fabrika db new <slug>` |
26
- | `test-only-import` | `@fabrika/app/testing`, `@fabrika/app/react/testing`, `@fabrika/db/testing` or `@fabrika/db/testing-sqlite` outside a test file | import them only from `*.test.ts` or `__tests__/` |
27
- | `spa-server-import` | the SPA loads a server module | the SPA imports `@fabrika/app/client`, the contract, `src/schemas.ts` and `src/shared/` |
28
- | `shared-import` | a module under `src/shared/` loads a slice, `src/db/`, `@fabrika/*` or a builtin | a shared module imports `zod/v4` and other shared modules only |
29
- | `skipped-test` | `.skip`, `.only`, `.todo` | run the test |
30
- | `file-column-migration` | a file column without `REFERENCES fabrika_files (id)` or without an index it leads | both, in a new migration (`references/table-and-migration.md`) |
31
- | `on-conflict-index` | `insert(…, { onConflict: { columns } })` whose columns are no unique index the migrations create | a `UNIQUE` constraint or a `CREATE UNIQUE INDEX` without `WHERE` on exactly those columns |
32
- | `cross-app-fetch` | a `fetch` of an absolute URL under no origin a site declares in `egress` | `egress: ['https://…']` on the site that calls it (`references/endpoint.md`) |
33
- | `url-endpoint` | `ctx.url('<id>')` names no procedure of the contract | the endpoint id `<mount>.<group>.<procedure>` |
34
- | `cloudflare-sql` | on Cloudflare, a site that declares `sql` | the handles (`ctx.db`), without `sql` |
35
- | `cloudflare-scan` | on Cloudflare, a file kind with `scan: true` | the kind without `scan`, and no public procedure that takes its files |
36
- | `cloudflare-current-timestamp` | on Cloudflare, `DEFAULT CURRENT_TIMESTAMP` in a migration: SQLite stores `YYYY-MM-DD HH:MM:SS`, which every read of the table refuses | `DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`, in a new migration |
11
+ | Rule | Means | Fix |
12
+ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
13
+ | `literal` | the shell (contract, vocabulary, tables, groups, schemas) holds a computed value or call | literal objects, `z.*` calls and imported literal consts; move logic into `.handle(…)` |
14
+ | `contract` | the contract does not compile (a reserved role, an unknown action, a wrong field) | the message names the field |
15
+ | `package-replacement` | `package.json` names the app under `@fabrika/*`, or a dependency is `workspace:*` | another name; a registry range such as `^0.0.4` |
16
+ | `generated-test-hash` | a generated test is missing, edited or stale | `bunx fabrika db test-scaffold`, and commit what it writes |
17
+ | `endpoint-fixture` | an endpoint's input cannot be sampled, a fixture or seed names no endpoint, a member, invitation or link field no endpoint that reads, cancels or revokes one, a file names no file field or a path outside the application, or a refusal is `validation` | a literal in `src/__tests__/endpoint-access.fixtures.ts` (`references/tests.md`) |
18
+ | `raw-sql` | a driver's `sql` (imported from `bun`, Kysely, `postgres`) | the handles, or `sql: 'read'` and `ctx.sql` |
19
+ | `forbidden-import` | a driver, an S3 client, `jose`, `node:http`, `child_process` | the handles, a file column with `ctx.files`, a connector |
20
+ | `forbidden-global` | `Bun.sql`, `Bun.serve`, `Bun.spawn`; `globalThis`, `window`, `self`, `global` or `Bun` in a server module | the handles; state lives in a table, not in a global; `crypto.randomUUID()` for an id |
21
+ | `env-read` | `process.env`, `Bun.env` | `defineDeps(build)`, which receives the application's variables |
22
+ | `client-identity` | an input field `userId`, `ownerId`, `createdBy`, `tenantId`, `workspaceId` | the access form's `from`, `ctx.principal.id`, or `createdBy` in the policy |
23
+ | `type-assertion` | `as T` (other than `as const`), `<T>x` | fix the type; `fromRow` parses a row into a schema's type |
24
+ | `sensitive-log` | a log of an error object, a request, headers or the environment | log a short fixed message |
25
+ | `migration-name` | a migration not named `YYYYMMDDHHMMSS_slug.sql` (UTC) | `bunx fabrika db new <slug>` |
26
+ | `test-only-import` | `@fabrika/app/testing`, `@fabrika/app/react/testing`, `@fabrika/db/testing` or `@fabrika/db/testing-sqlite` outside a test file | import them only from `*.test.ts` or `__tests__/` |
27
+ | `spa-server-import` | the SPA loads a server module | the SPA imports `@fabrika/app/client`, the contract, `src/schemas.ts` and `src/shared/` |
28
+ | `shared-import` | a module under `src/shared/` loads a slice, `src/db/`, `@fabrika/*` or a builtin | a shared module imports `zod/v4` and other shared modules only |
29
+ | `skipped-test` | `.skip`, `.only`, `.todo` | run the test |
30
+ | `file-column-migration` | a file column without `REFERENCES fabrika_files (id)` or without an index it leads | both, in a new migration (`references/table-and-migration.md`) |
31
+ | `on-conflict-index` | `insert(…, { onConflict: { columns } })` whose columns are no unique index the migrations create | a `UNIQUE` constraint or a `CREATE UNIQUE INDEX` without `WHERE` on exactly those columns |
32
+ | `cross-app-fetch` | a `fetch` of an absolute URL under no origin a site declares in `egress`, or one a job's `egress: 'public'` refuses (an IP address, `localhost`) | `egress: ['https://…']` on the site that calls it (`references/endpoint.md`) |
33
+ | `url-endpoint` | `ctx.url('<id>')` names no procedure of the contract | the endpoint id `<mount>.<group>.<procedure>` |
34
+ | `cloudflare-sql` | on Cloudflare, a site that declares `sql` | the handles (`ctx.db`), without `sql` |
35
+ | `cloudflare-scan` | on Cloudflare, a file kind with `scan: true` | the kind without `scan`, and no public procedure that takes its files |
36
+ | `cloudflare-current-timestamp` | on Cloudflare, `DEFAULT CURRENT_TIMESTAMP` in a migration: SQLite stores `YYYY-MM-DD HH:MM:SS`, which every read of the table refuses | `DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))`, in a new migration |
37
37
 
38
38
  The hints:
39
39
 
@@ -103,11 +103,11 @@ A 500 answers a fixed message; the server log has one line, `fabrika: <endpoint>
103
103
  fixed failure code and, for a framework refusal, its reason and the reason's fixed text, never a value or your error's
104
104
  message. The codes you meet most:
105
105
 
106
- | Code | Means |
107
- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
108
- | `query_refused` | the data layer refused a statement or an operation: `undeclared_reach`, `sql_read_only`, `unknown_column`, `read_only`, `sql_outside_transaction`, `sql_dialect` (`ctx.sql` on SQLite, Cloudflare) …; the reason's text names the rule |
109
- | `capability_refused` | a capability of another kind, dimension or action than the call takes (`wrong_capability`, `wrong_action`, `out_of_reach`), or one kept past its request (`ended`, `foreign_work`) |
110
- | `handler_failed` | your handler threw something else; the log has no more: the Operations issue has the stack, or test the handler |
106
+ | Code | Means |
107
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
108
+ | `query_refused` | the data layer refused a statement or an operation: `undeclared_reach`, `sql_read_only`, `unknown_column`, `read_only`, `sql_outside_transaction`, `sql_dialect` (`ctx.sql` on SQLite, Cloudflare) …; the reason's text names the rule |
109
+ | `capability_refused` | a capability of another kind, dimension or action than the call takes (`wrong_capability`, `wrong_action`, `out_of_reach`), one kept past its request (`ended`, `foreign_work`), or a `fetch` its `egress` refuses (`undeclared_egress`, `refused_egress`, `oversized_egress`) |
110
+ | `handler_failed` | your handler threw something else; the log has no more: the Operations issue has the stack, or test the handler |
111
111
 
112
112
  With an Operations DSN the same 500 is a `RequestFailed` issue in Operations. Its event also carries what your handler
113
113
  threw: the error's type, its message, its stack and its `cause` chain. Read the stack there, not in the log. A crash
@@ -144,6 +144,15 @@ export const contract = app.contract((c) => ({
144
144
  - A job may also declare `kicks` (another job; the edges never form a cycle), `signs` (`ctx.tokens.sign`),
145
145
  `egress` (the HTTPS origins its `fetch` calls) and `issues` (`references/endpoint.md`). `ctx.url('<endpoint id>', …)`
146
146
  builds an absolute link to one of the application's procedures, for an e-mail.
147
+ - A job that calls the websites its operators enter (a crawler, an auditor) declares `egress: 'public'`:
148
+ `app.cron('*/5 * * * *', { actions: ['sites.read'], egress: 'public' })`. Its `fetch` reaches any public host over
149
+ HTTPS or HTTP on the default port. It is refused (`capability_refused: refused_egress`) for an IP address, a name of
150
+ one label (`localhost`, an internal service), a name under `.local`, `.internal`, `.workers.dev`, `.zerops.app` and similar, a port,
151
+ the installation's own hosts (IAM, Control, Operations, every application's domain) and, on Bun (Zerops), a name
152
+ that resolves to a private address. Every redirect is checked again: `redirect: 'manual'` gets the redirect, the default
153
+ follows at most 10. A request has 60 s, a response body at most 16 MiB (`oversized_egress`); pass `ctx.signal`, or
154
+ `AbortSignal.any([ctx.signal, AbortSignal.timeout(8_000)])` for a shorter bound per page. The approver reviews it
155
+ closely, as reach to anywhere public. Keep the sites operators enter in a table the job reads.
147
156
  - `ctx.signal` aborts when the attempt ends (its timeout, a lost lease). Call `ctx.signal.throwIfAborted()` before
148
157
  each external effect and pass the signal to `fetch`, so a stale attempt starts nothing after its replacement.
149
158
  - A failed attempt names its failure in `kit.runJob`'s answer and the log: `handler_failed`, a framework refusal
@@ -12,7 +12,7 @@ longer writes. Commit them; never edit, format or import them (`generated-test-h
12
12
  `app` or `creates` endpoint refuses a holder of its action in one scope only (403) and answers a global holder. An
13
13
  endpoint of several `capabilities` adds a denied case per capability. A public procedure answers anyone without a
14
14
  credential, or, behind a challenge, refuses a caller without a solved one. A file input is sent as a generated file
15
- of its kind; a `GET` procedure's input goes in its URL.
15
+ of its kind, or as the file the fixtures name (`files`, below); a `GET` procedure's input goes in its URL.
16
16
  - Their kit stands in for IAM, as `testKit` does (below): it creates a scope, assigns or invites a member and issues a
17
17
  link under IAM's rules for the caller's grants. A repeated invitation answers the open one, `resend` restarts its
18
18
  7 days, and an organization's address is refused for an external role (`internal_mailbox`). So the caller of an endpoint with the dimension's `members.manage`
@@ -67,6 +67,27 @@ export const members = { 'api.tasks.assign': ['assignee'] }
67
67
  to another holds that row's key. A text column may be given; the key, the version, the creator and a file column may
68
68
  not (a file column holds a live, scanned file). Only application tables, and only for an endpoint with a leak or an
69
69
  own-object case: the checker refuses a table in a scope, which `rows` and `seeds` address (`endpoint-fixture`).
70
+ - `files['<endpoint id>']` names, by file field, a file of the repository that every case of the endpoint sends there
71
+ instead of the generated one, a path from the application's root:
72
+ `export const files = { 'api.skills.publish': { package: 'src/__tests__/fixtures/skill.zip' } }`. Give one when the
73
+ handler inspects the content (a package it unpacks, a document it parses) and answers 400 to the generated file, which
74
+ has only the leading bytes of its type. Commit the file; keep it small and free of real data. The kit sends it with the
75
+ first type of the field's kind its bytes are, and a file it cannot read, or of none of those types, fails the case. The
76
+ checker refuses a field that holds no file and a path that leaves the application, starts with `.` or has characters
77
+ other than letters, digits, `_`, `-`, `.` and `/` (`endpoint-fixture`).
78
+ - `pins['<endpoint id>']` gives text columns of the row the endpoint addresses (the row of the table that owns its
79
+ dimension) a known value instead of the seed's marker: `export const pins = { 'api.skills.publish': { slug:
80
+ 'published-skill' } }`, when the handler ties the column to the input or to the file in `files` (a package whose
81
+ `SKILL.md` names the skill's slug). The own scope's row holds the value, the other scope's `<value>-other`, which a
82
+ leak case still looks for in the own scope's answer. A pin of a column that places the row, or of no text column,
83
+ fails the case; the checker refuses one of an endpoint without an addressed row (`endpoint-fixture`).
84
+ - `refusals['<endpoint id>']` lists the error types of the 400s the endpoint's handler answers with when its rule ties
85
+ the row to the input and the scope is the other one: `export const refusals = { 'api.skills.publish':
86
+ ['skill_frontmatter'] }`, beside the pin above. A leak case asks from the other scope first, whose pinned value the
87
+ package does not name; that request may answer one of them. The own case passes on 200 or one of them, and the
88
+ denial cases still expect 404 or 403. The answer a leak case checks, the own scope's, still fails on it, because an
89
+ answer that holds nothing proves no isolation: pin the row and give the file so that the own scope succeeds. Any
90
+ other 400 fails the case, and the checker refuses `validation`, an input the schema refuses (`endpoint-fixture`).
70
91
  - The endpoint id is `<mount>.<group>.<endpoint>` (`api.tasks.close`).
71
92
  - The seed of the tests fills every text column with a marker and every other required column with a sample of its
72
93
  type: an enum's first label, `1`, `false`, today. A column with a `DEFAULT` keeps it (`status` is `open`). A column
package/src/build.ts CHANGED
@@ -25,7 +25,12 @@ export type EntryRuntime = 'bun' | 'cloudflare'
25
25
  export * from './build-recipe.js'
26
26
 
27
27
  // In a module of their own, which the Worker imports without this one's file system.
28
- export { CLOUDFLARE_APPLICATION_BINDING, CLOUDFLARE_APPLICATION_CLASS, CLOUDFLARE_FILES_BINDING } from './cloudflare/names.js'
28
+ export {
29
+ CLOUDFLARE_APPLICATION_BINDING,
30
+ CLOUDFLARE_APPLICATION_CLASS,
31
+ CLOUDFLARE_ASSETS_BINDING,
32
+ CLOUDFLARE_FILES_BINDING,
33
+ } from './cloudflare/names.js'
29
34
 
30
35
  const HEADER = '// @generated by `fabrika app build` from src/app.ts. Do not edit: every build rewrites it.'
31
36
 
package/src/bun.ts CHANGED
@@ -14,6 +14,7 @@ import { directoryEmailProvider } from '@fabrika/email/directory'
14
14
  import { parseJwks } from '@fabrika/jwks'
15
15
  import type { FileObjectStore } from '@fabrika/platform'
16
16
  import { applicationRuntimeUrl, S3BlobStore } from '@fabrika/platform-node'
17
+ import { lookup } from 'node:dns/promises'
17
18
  import { diskAssets } from './disk-assets.js'
18
19
  import type { FetchService, RequestExecutionContext } from './fetch.js'
19
20
  import { clamscanScanner } from './files/clamscan.js'
@@ -405,6 +406,7 @@ async function startContractServer(
405
406
  membership,
406
407
  terminals,
407
408
  workloadTokens,
409
+ resolveHost,
408
410
  requireProxySecret: true,
409
411
  localEmailProviders: LOCAL_EMAIL_PROVIDERS,
410
412
  connections: databaseConnections(environment.framework[DATABASE_CONNECTIONS]) ?? DEFAULT_DATABASE_CONNECTIONS,
@@ -432,6 +434,13 @@ async function startContractServer(
432
434
  return { port: server.port ?? port, stop }
433
435
  }
434
436
 
437
+ /**
438
+ * Every address `hostname` resolves to, as the operating system's resolver answers Bun's own `fetch`: a cron job's public
439
+ * egress refuses a hop whose name resolves to any private one (ADR-0070).
440
+ */
441
+ const resolveHost = async (hostname: string): Promise<readonly string[]> =>
442
+ (await lookup(hostname, { all: true, verbatim: true })).map((entry) => entry.address)
443
+
435
444
  /** The application module at `path`, imported when the loader runs; its default export must be what `serve` returned. */
436
445
  export function implementationAt(path: string): ImplementationLoader {
437
446
  return async () => {
@@ -478,6 +487,7 @@ export async function runContractJob(
478
487
  membership,
479
488
  terminals,
480
489
  workloadTokens,
490
+ resolveHost,
481
491
  requireProxySecret: true,
482
492
  localEmailProviders: LOCAL_EMAIL_PROVIDERS,
483
493
  // ClamAV on PATH, as the provider's build installs it for a `scan: true` file kind; missing, the scan fails closed.
@@ -19,7 +19,7 @@ import type { CrashReporter } from '../jobs/report.js'
19
19
  import { isDueAt } from '../jobs/schedule.js'
20
20
  import { environmentScrubber, type Scrubber } from '../jobs/scrub.js'
21
21
  import { bindingAssets, bindingKeySource, bindingMembership, bindingTerminals, bindingWorkloadTokens, connectBindings } from './bindings.js'
22
- import { CLOUDFLARE_FILES_BINDING } from './names.js'
22
+ import { CLOUDFLARE_ASSETS_BINDING, CLOUDFLARE_FILES_BINDING } from './names.js'
23
23
  import { type OpsAccess, opsAccess, type OpsStorage, serveOps } from './ops.js'
24
24
  import { r2FileObjectStore } from './r2-files.js'
25
25
 
@@ -58,7 +58,7 @@ export interface ApplicationState {
58
58
  }
59
59
 
60
60
  /** The framework's bindings: IAM's application entrypoint and the Workers Assets of the SPA. No handler sees them. */
61
- const FRAMEWORK_BINDINGS: ReadonlySet<string> = new Set(['IAM', 'ASSETS'])
61
+ const FRAMEWORK_BINDINGS: ReadonlySet<string> = new Set(['IAM', CLOUDFLARE_ASSETS_BINDING])
62
62
 
63
63
  /**
64
64
  * The application this isolate booted, once: its module is loaded once, and its `scoped(tables)` layer binds once, to
@@ -138,7 +138,7 @@ async function bootApplication(
138
138
  const app = boot(implementation, {
139
139
  environment,
140
140
  iam: bindingKeySource(env['IAM']),
141
- assets: bindingAssets(env['ASSETS']),
141
+ assets: bindingAssets(env[CLOUDFLARE_ASSETS_BINDING]),
142
142
  membership: bindingMembership(env['IAM']),
143
143
  terminals: bindingTerminals(env['IAM']),
144
144
  workloadTokens: bindingWorkloadTokens(env['IAM']),
@@ -9,6 +9,7 @@ import type { IamKeySource } from '../implement/boot.js'
9
9
  import { CONNECT_BINDING_PREFIX, type ConnectFetch } from '../implement/connect.js'
10
10
  import type { SpaAssets } from '../implement/dispatch.js'
11
11
  import type { WorkloadTokenSource } from '../implement/workload.js'
12
+ import { isPageRoute } from '../spa-fallback.js'
12
13
 
13
14
  /** The method `name` of a binding, bound to it, or `null` when it has none. */
14
15
  export function method(target: unknown, name: string): ((...args: unknown[]) => unknown) | null {
@@ -96,18 +97,38 @@ export function bindingWorkloadTokens(binding: unknown): WorkloadTokenSource | u
96
97
  }
97
98
  }
98
99
 
100
+ /**
101
+ * The SPA and link mounts' files from the Workers Assets binding, whose directory mirrors the request paths: the mount
102
+ * `/admin` reads `admin/…` of it, the root mount the rest (the Cloudflare deploy stages each mount's `dir` there). A page
103
+ * route that names no file (`isPageRoute`) gets the mount's `index.html`, as `diskAssets` serves it: Workers Assets
104
+ * answers `<prefix>/` with it. A missing asset stays 404.
105
+ */
99
106
  export function bindingAssets(binding: unknown): SpaAssets | undefined {
100
107
  const fetchAsset = method(binding, 'fetch')
101
108
  if (fetchAsset === null) {
102
109
  return undefined
103
110
  }
111
+ const fetched = async (request: Request): Promise<Response> => {
112
+ const response: unknown = await fetchAsset(request)
113
+ if (!(response instanceof Response)) {
114
+ throw new Error('the ASSETS binding answered something that is not a Response')
115
+ }
116
+ return response
117
+ }
104
118
  return {
105
- async serve(_mount, request) {
106
- const response: unknown = await fetchAsset(request)
107
- if (!(response instanceof Response)) {
108
- throw new Error('the ASSETS binding answered something that is not a Response')
119
+ async serve(mount, request) {
120
+ const response = await fetched(request)
121
+ if (response.status !== 404 || !isPageRoute(request)) {
122
+ return response
123
+ }
124
+ const index = new URL(request.url)
125
+ index.pathname = mount.prefix === '/' ? '/' : `${mount.prefix}/`
126
+ index.search = ''
127
+ if (index.pathname === new URL(request.url).pathname) {
128
+ return response
109
129
  }
110
- return response
130
+ await response.body?.cancel()
131
+ return await fetched(new Request(index, { method: request.method, headers: request.headers }))
111
132
  },
112
133
  }
113
134
  }
@@ -8,3 +8,9 @@ export const CLOUDFLARE_APPLICATION_BINDING = 'FABRIKA_APPLICATION'
8
8
 
9
9
  /** The R2 bucket binding of the environment's stored files. */
10
10
  export const CLOUDFLARE_FILES_BINDING = 'FABRIKA_FILES'
11
+
12
+ /**
13
+ * The Workers Assets binding the SPA and link mounts serve their files from. Its directory mirrors the request paths:
14
+ * the mount `/admin` serves `<directory>/admin/…`, the root mount `<directory>/…`.
15
+ */
16
+ export const CLOUDFLARE_ASSETS_BINDING = 'ASSETS'
@@ -14,12 +14,14 @@ import {
14
14
  FILE_RESPONSE_KINDS,
15
15
  isOwnWorkloadName,
16
16
  JOB_MAILERS,
17
+ JOB_SITE_PREFIX,
17
18
  LOCALES,
18
19
  mailTemplateProblems,
19
20
  MAX_CACHE_AGE_SECONDS,
20
21
  type MessageKind,
21
22
  type NumberShape,
22
23
  type OutputShape,
24
+ PUBLIC_EGRESS,
23
25
  type RecordShape,
24
26
  type SchemaShape,
25
27
  type StringShape,
@@ -411,9 +413,12 @@ export const contractArtifactSchema: z.ZodType<ContractArtifact> = z.strictObjec
411
413
  kicks: z.array(name).nonempty().optional(),
412
414
  binds: name.optional(),
413
415
  signs: z.array(name).nonempty().optional(),
414
- egress: z.array(z.string().refine((origin) => isPolicyOrigin(origin))).nonempty().optional(),
416
+ egress: z.union([z.array(z.string().refine((origin) => isPolicyOrigin(origin))).nonempty(), z.literal(PUBLIC_EGRESS)]).optional(),
415
417
  })).superRefine((declared, context) => {
416
418
  for (const [site, reach] of Object.entries(declared)) {
419
+ if (reach.egress === PUBLIC_EGRESS && !site.startsWith(JOB_SITE_PREFIX)) {
420
+ context.addIssue({ code: 'custom', path: [site, 'egress'], message: "egress: 'public' belongs to a cron job, keyed by job:<name>" })
421
+ }
417
422
  if (reach.actions !== undefined && !isOwnWorkloadName('public', site)) {
418
423
  context.addIssue({ code: 'custom', path: [site, 'actions'], message: 'workload actions belong to a public procedure, keyed by its endpoint id' })
419
424
  }
@@ -18,6 +18,7 @@ import {
18
18
  type ContractArtifact,
19
19
  type CspExtensions,
20
20
  type CspSource,
21
+ type DeclaredEgress,
21
22
  type DeclaredReach,
22
23
  type DimensionArtifact,
23
24
  directoryReach,
@@ -52,6 +53,7 @@ import {
52
53
  planEndpoint,
53
54
  type ProcedureArtifact,
54
55
  type ProcedureChallenge,
56
+ PUBLIC_EGRESS,
55
57
  type RoleArtifact,
56
58
  type SchemaShape,
57
59
  type ScopeBinding,
@@ -147,7 +149,7 @@ const FRAMEWORK_TABLE_PREFIX = 'fabrika_'
147
149
  /** The most origins one site calls: a site that reaches more is an integration hub, which a connector import serves. */
148
150
  const MAX_EGRESS_ORIGINS = 16
149
151
  const DECLARED_ALLOWED =
150
- "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', mailer: 'installation' | 'own', kicks: ['<cron job>'], binds: '<webhook>', signs: ['<token purpose>'], egress: ['https://<host>']"
152
+ "sql: 'read' | 'write', unscoped: { <table>: 'read' | 'write' }, connect: ['<import>'], issues: '<link mount>', mailer: 'installation' | 'own', kicks: ['<cron job>'], binds: '<webhook>', signs: ['<token purpose>'], egress: ['https://<host>'] | 'public' (a cron job)"
151
153
  const CSP_FIELDS = ['connectSrc', 'imgSrc', 'frameAncestors', 'scriptSrc', 'frameSrc', 'styleSrc', 'fontSrc']
152
154
  /** An environment variable an application reads; a challenge names its secret by one. */
153
155
  const APPLICATION_VARIABLE = /^[A-Z][A-Z0-9_]*$/
@@ -2106,7 +2108,7 @@ class ArtifactBuilder {
2106
2108
  ...(kicks.length === 0 ? {} : { kicks }),
2107
2109
  ...(binds === undefined ? {} : { binds }),
2108
2110
  ...(signs.length === 0 ? {} : { signs }),
2109
- ...(egress.length === 0 ? {} : { egress }),
2111
+ ...(egress === null ? {} : { egress }),
2110
2112
  }
2111
2113
  if (place.kind !== 'rpc') {
2112
2114
  this.#rpcOnly(where, 'connect', value['connect'])
@@ -2305,12 +2307,13 @@ class ArtifactBuilder {
2305
2307
 
2306
2308
  /**
2307
2309
  * The HTTPS origins a site's code calls: a credentialed procedure of an RPC mount, a webhook or a job, never a public,
2308
- * export or link procedure. Each is an exact origin with no path or wildcard, once; `[]` when it declares none or after
2309
- * a problem.
2310
+ * export or link procedure. Each is an exact origin with no path or wildcard, once. Or `'public'`, every public host
2311
+ * (ADR-0070), which only a cron job declares: it calls outside any transaction, and its schedule, not a caller,
2312
+ * decides when. `null` when it declares none or after a problem.
2310
2313
  */
2311
- #egress(where: string, value: unknown, place: DeclaringSite): string[] {
2314
+ #egress(where: string, value: unknown, place: DeclaringSite): DeclaredEgress | null {
2312
2315
  if (value === undefined) {
2313
- return []
2316
+ return null
2314
2317
  }
2315
2318
  const refusedBy = place.kind === 'export'
2316
2319
  ? 'an export procedure'
@@ -2321,15 +2324,35 @@ class ArtifactBuilder {
2321
2324
  : null
2322
2325
  if (refusedBy !== null) {
2323
2326
  this.#problem(where, `declares egress, which ${refusedBy} never does: a credentialed procedure, a webhook or a job calls out`)
2324
- return []
2327
+ return null
2328
+ }
2329
+ if (value === PUBLIC_EGRESS) {
2330
+ if (place.kind !== 'job') {
2331
+ this.#problem(
2332
+ where,
2333
+ `declares egress: '${PUBLIC_EGRESS}', which only a cron job declares: a procedure or a webhook names its origins (allowed: egress: ['https://api.example.com'])`,
2334
+ )
2335
+ return null
2336
+ }
2337
+ return PUBLIC_EGRESS
2325
2338
  }
2326
2339
  if (!Array.isArray(value) || value.length === 0 || !value.every((origin) => typeof origin === 'string')) {
2327
- this.#problem(where, "egress is not a list of origins (allowed: egress: ['https://api.example.com'])")
2328
- return []
2340
+ this.#problem(
2341
+ where,
2342
+ place.kind === 'job'
2343
+ ? "egress is not a list of origins or 'public' (allowed: egress: ['https://api.example.com'] or egress: 'public')"
2344
+ : "egress is not a list of origins (allowed: egress: ['https://api.example.com'])",
2345
+ )
2346
+ return null
2329
2347
  }
2330
2348
  const origins: string[] = value.filter((origin) => typeof origin === 'string')
2331
2349
  if (origins.length > MAX_EGRESS_ORIGINS) {
2332
- this.#problem(where, `${origins.length} egress origins exceed the limit of ${MAX_EGRESS_ORIGINS}`)
2350
+ this.#problem(
2351
+ where,
2352
+ `${origins.length} egress origins exceed the limit of ${MAX_EGRESS_ORIGINS}${
2353
+ place.kind === 'job' ? " (a cron job that calls the websites its operators name declares egress: 'public')" : ''
2354
+ }`,
2355
+ )
2333
2356
  }
2334
2357
  for (const origin of origins) {
2335
2358
  if (!isPolicyOrigin(origin)) {
@@ -115,7 +115,16 @@ export type SignsField<Sg, Purposes extends string> =
115
115
  * `fetch` refuses any other while the site runs; a public procedure declares none.
116
116
  */
117
117
  export interface EgressField {
118
- readonly egress?: readonly `https://${string}`[]
118
+ readonly egress?: readonly `https://${string}`[] | Unavailable<"egress: 'public' is a cron job's only: record a row and kick a job that declares it">
119
+ }
120
+
121
+ /**
122
+ * A cron job's `egress`: its exact HTTPS origins, or `'public'`, every public host over HTTPS or HTTP on its default
123
+ * port, for a job that calls the websites its operators name (ADR-0070). The guard refuses IP addresses, local and
124
+ * internal names and the installation's own hosts, checks each redirect, and bounds each request's time and body.
125
+ */
126
+ export interface JobEgressField {
127
+ readonly egress?: readonly `https://${string}`[] | 'public'
119
128
  }
120
129
 
121
130
  /** Each purpose `signs` names is one of `defineApp`'s `tokens`. */
@@ -57,6 +57,7 @@ import type {
57
57
  GroupBuild,
58
58
  GroupInput,
59
59
  GroupKit,
60
+ JobEgressField,
60
61
  KicksField,
61
62
  LimitsOption,
62
63
  LinkGroupKit,
@@ -96,7 +97,7 @@ export type CronOptions<Acts, S, U, Is, Ix extends AnyDbIndex, M = never, Ks = n
96
97
  & MailerField<M>
97
98
  & KicksField<Ks>
98
99
  & SignsField<Sg, Purposes>
99
- & EgressField
100
+ & JobEgressField
100
101
 
101
102
  /** The mail transport a job reaches, inferred from its optional field and then required, like a declared field. */
102
103
  type MailerField<M> =