@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.
- package/package.json +9 -9
- package/skill/SKILL.md +4 -2
- package/skill/references/app-setup.md +29 -11
- package/skill/references/endpoint.md +5 -0
- package/skill/references/errors.md +31 -31
- package/skill/references/jobs.md +9 -0
- package/skill/references/tests.md +22 -1
- package/src/build.ts +6 -1
- package/src/bun.ts +10 -0
- package/src/cloudflare/application.ts +3 -3
- package/src/cloudflare/bindings.ts +26 -5
- package/src/cloudflare/names.ts +6 -0
- package/src/contract/artifact-schema.ts +6 -1
- package/src/contract/build.ts +33 -10
- package/src/contract/group.ts +10 -1
- package/src/contract/kit.ts +2 -1
- package/src/disk-assets.ts +8 -3
- package/src/implement/boot.ts +9 -3
- package/src/implement/capability.ts +3 -0
- package/src/implement/dispatch.ts +4 -4
- package/src/implement/egress.ts +341 -8
- package/src/implement/invocation.ts +10 -9
- package/src/jobs/runner.ts +7 -2
- package/src/spa-fallback.ts +12 -0
- package/src/testing/endpoint-access.ts +135 -23
- package/src/testing/isolation.ts +8 -1
- package/types/src/build.d.ts +1 -1
- package/types/src/cloudflare/bindings.d.ts +6 -0
- package/types/src/cloudflare/names.d.ts +5 -0
- package/types/src/contract/group.d.ts +9 -1
- package/types/src/contract/kit.d.ts +2 -2
- package/types/src/disk-assets.d.ts +4 -1
- package/types/src/implement/boot.d.ts +6 -0
- package/types/src/implement/capability.d.ts +2 -0
- package/types/src/implement/dispatch.d.ts +2 -1
- package/types/src/implement/egress.d.ts +36 -1
- package/types/src/implement/invocation.d.ts +3 -3
- package/types/src/jobs/runner.d.ts +3 -0
- package/types/src/spa-fallback.d.ts +2 -0
- package/types/src/testing/endpoint-access.d.ts +25 -1
- 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.
|
|
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.
|
|
113
|
-
"@fabrika/auth-core": "0.9.0-cloudflare.
|
|
114
|
-
"@fabrika/db": "0.9.0-cloudflare.
|
|
115
|
-
"@fabrika/email": "0.9.0-cloudflare.
|
|
116
|
-
"@fabrika/jwks": "0.9.0-cloudflare.
|
|
117
|
-
"@fabrika/platform": "0.9.0-cloudflare.
|
|
118
|
-
"@fabrika/platform-node": "0.9.0-cloudflare.
|
|
119
|
-
"@fabrika/proxy-core": "0.9.0-cloudflare.
|
|
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.
|
|
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.
|
|
23
|
-
"@fabrika/db": "0.9.0-cloudflare.
|
|
24
|
-
"@fabrika/provider-zerops": "0.9.0-cloudflare.
|
|
25
|
-
"@fabrika/proxy-core": "0.9.0-cloudflare.
|
|
26
|
-
"@fabrika/ui": "0.9.0-cloudflare.
|
|
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.
|
|
34
|
-
"@fabrika/cli": "0.9.0-cloudflare.
|
|
35
|
-
"@fabrika/platform": "0.9.0-cloudflare.
|
|
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
|
|
383
|
-
under it, so a deep link such as `/tasks/<id>` renders
|
|
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
|
|
12
|
-
| ------------------------------ |
|
|
13
|
-
| `literal` | the shell (contract, vocabulary, tables, groups, schemas) holds a computed value or call
|
|
14
|
-
| `contract` | the contract does not compile (a reserved role, an unknown action, a wrong field)
|
|
15
|
-
| `package-replacement` | `package.json` names the app under `@fabrika/*`, or a dependency is `workspace:*`
|
|
16
|
-
| `generated-test-hash` | a generated test is missing, edited or stale
|
|
17
|
-
| `endpoint-fixture` | an endpoint's input cannot be sampled, a fixture or seed names no endpoint,
|
|
18
|
-
| `raw-sql` | a driver's `sql` (imported from `bun`, Kysely, `postgres`)
|
|
19
|
-
| `forbidden-import` | a driver, an S3 client, `jose`, `node:http`, `child_process`
|
|
20
|
-
| `forbidden-global` | `Bun.sql`, `Bun.serve`, `Bun.spawn`; `globalThis`, `window`, `self`, `global` or `Bun` in a server module
|
|
21
|
-
| `env-read` | `process.env`, `Bun.env`
|
|
22
|
-
| `client-identity` | an input field `userId`, `ownerId`, `createdBy`, `tenantId`, `workspaceId`
|
|
23
|
-
| `type-assertion` | `as T` (other than `as const`), `<T>x`
|
|
24
|
-
| `sensitive-log` | a log of an error object, a request, headers or the environment
|
|
25
|
-
| `migration-name` | a migration not named `YYYYMMDDHHMMSS_slug.sql` (UTC)
|
|
26
|
-
| `test-only-import` | `@fabrika/app/testing`, `@fabrika/app/react/testing`, `@fabrika/db/testing` or `@fabrika/db/testing-sqlite` outside a test file
|
|
27
|
-
| `spa-server-import` | the SPA loads a server module
|
|
28
|
-
| `shared-import` | a module under `src/shared/` loads a slice, `src/db/`, `@fabrika/*` or a builtin
|
|
29
|
-
| `skipped-test` | `.skip`, `.only`, `.todo`
|
|
30
|
-
| `file-column-migration` | a file column without `REFERENCES fabrika_files (id)` or without an index it leads
|
|
31
|
-
| `on-conflict-index` | `insert(…, { onConflict: { columns } })` whose columns are no unique index the migrations create
|
|
32
|
-
| `cross-app-fetch` | a `fetch` of an absolute URL under no origin a site declares in `egress`
|
|
33
|
-
| `url-endpoint` | `ctx.url('<id>')` names no procedure of the contract
|
|
34
|
-
| `cloudflare-sql` | on Cloudflare, a site that declares `sql`
|
|
35
|
-
| `cloudflare-scan` | on Cloudflare, a file kind with `scan: true`
|
|
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
|
|
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`),
|
|
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
|
package/skill/references/jobs.md
CHANGED
|
@@ -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 {
|
|
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',
|
|
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[
|
|
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(
|
|
106
|
-
const response
|
|
107
|
-
if (
|
|
108
|
-
|
|
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
|
-
|
|
130
|
+
await response.body?.cancel()
|
|
131
|
+
return await fetched(new Request(index, { method: request.method, headers: request.headers }))
|
|
111
132
|
},
|
|
112
133
|
}
|
|
113
134
|
}
|
package/src/cloudflare/names.ts
CHANGED
|
@@ -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
|
}
|
package/src/contract/build.ts
CHANGED
|
@@ -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
|
|
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
|
|
2309
|
-
* a
|
|
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):
|
|
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(
|
|
2328
|
-
|
|
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(
|
|
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)) {
|
package/src/contract/group.ts
CHANGED
|
@@ -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`. */
|
package/src/contract/kit.ts
CHANGED
|
@@ -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
|
-
&
|
|
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> =
|