@fabrika/app 0.5.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +387 -236
  10. package/skill/references/spa.md +5 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. package/src/files/collections.ts +0 -848
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.5.5",
3
+ "version": "0.7.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -93,14 +93,14 @@
93
93
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
94
94
  },
95
95
  "dependencies": {
96
- "@fabrika/auth": "0.5.5",
97
- "@fabrika/auth-core": "0.5.5",
98
- "@fabrika/db": "0.5.5",
99
- "@fabrika/email": "0.5.5",
100
- "@fabrika/jwks": "0.5.5",
101
- "@fabrika/platform": "0.5.5",
102
- "@fabrika/platform-node": "0.5.5",
103
- "@fabrika/proxy-core": "0.5.5",
96
+ "@fabrika/auth": "0.7.0",
97
+ "@fabrika/auth-core": "0.7.0",
98
+ "@fabrika/db": "0.7.0",
99
+ "@fabrika/email": "0.7.0",
100
+ "@fabrika/jwks": "0.7.0",
101
+ "@fabrika/platform": "0.7.0",
102
+ "@fabrika/platform-node": "0.7.0",
103
+ "@fabrika/proxy-core": "0.7.0",
104
104
  "jose": "^5.9.0"
105
105
  },
106
106
  "peerDependencies": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.5.5
3
+ version: 0.7.0
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
@@ -204,7 +204,9 @@ export const tasks = app.group({ callers: ['human'] }, (r) => ({
204
204
  `@fabrika/app`. Any thrown value with a numeric `httpStatus` below 500, a `type` and a `message` answers that status
205
205
  (the framework's other errors are such values); any other throw is a 500.
206
206
  - One action per endpoint. A column that another action writes gets its own write action and its own endpoint
207
- (`assign` above). Never check a second action inside a handler: split the endpoint.
207
+ (`assign` above). Never check a second action inside a handler: split the endpoint. An operation that reads one
208
+ object and writes another (a copy from one deal to another) declares both under
209
+ `access: { capabilities: { <name>: <form>, … } }` (`references/endpoint.md`).
208
210
  - Never take a user, owner or tenant id from the input, nor the caller's own address. The scope comes from the access
209
211
  form; `ctx.principal.id` is the caller, and `ctx.principal.email` its address. A person someone picks (`assign` above) is checked with `ctx.members.get(id)`, which answers `null` for
210
212
  anyone outside the org, and its label comes from that answer, never from the input.
@@ -220,15 +222,19 @@ the access check. `references/spa.md` shows the stack.
220
222
 
221
223
  ## Off the road: declare it
222
224
 
223
- | What the handler needs | Declare on the endpoint | It gets |
224
- | ------------------------------ | ---------------------------------------------- | --------------------------------------------------- |
225
- | a join, a report, a bulk write | `sql: 'read'` (or `'write'`) | `` ctx.sql(Row)`…` ``, scoped with `ctx.sql.scope` |
226
- | rows outside its capability | `unscoped: { <table>: 'read' }` | `ctx.db.unscoped.<table>` |
227
- | another application's export | `connect: ['<import>']` | `ctx.connect.<import>` |
228
- | a share link | `issues: '<link mount>'` | `ctx.links.issue` |
229
- | e-mail (a cron job only) | `mailer: 'installation'` | `ctx.mail.send` |
230
- | a file with a public form | `files: { photo: { collection } }` on `c.form` | the id in the inbox; the job's `tx.files(…).attach` |
231
- | a trigger, function or view | its migration | — |
225
+ | What the handler needs | Declare on the endpoint | It gets |
226
+ | ------------------------------ | ------------------------------------------------------- | -------------------------------------------------- |
227
+ | a join, a report, a bulk write | `sql: 'read'` (or `'write'`) | `` ctx.sql(Row)`…` ``, scoped with `ctx.sql.scope` |
228
+ | rows outside its capability | `unscoped: { <table>: 'read' }` | `ctx.db.unscoped.<table>` |
229
+ | another application's export | `connect: ['<import>']` | `ctx.connect.<import>` |
230
+ | a share link | `issues: '<link mount>'` | `ctx.links.issue` |
231
+ | e-mail (a cron job only) | `mailer: 'installation'` | `ctx.mail.send` |
232
+ | a file from a public procedure | `file('<kind>')` input, `admission` with `bytesPerHour` | a reference the handler writes into a file column |
233
+ | an outside HTTPS API | `egress: ['https://api.example.com']` | `fetch` to exactly those origins |
234
+ | a signed link (unsubscribe) | `signs: ['<purpose>']` | `ctx.tokens.sign` |
235
+ | a webhook binding | `binds: '<webhook>'` | `ctx.webhooks('<webhook>').bind` and `.unbind` |
236
+ | a job run after the commit | `kicks: ['<job>']` | `ctx.kick('<job>')` |
237
+ | a trigger, function or view | its migration | — |
232
238
 
233
239
  Scope every scoped table a raw statement reads with `${ctx.sql.scope(<capability>, '<table>', '<alias>')}`, a conjunct
234
240
  of its WHERE or JOIN … ON (`where (<filter>) and ${…}`): `ctx.target` for `on` and `in`, `ctx.within` for `over`, a
@@ -246,7 +252,7 @@ Which promise the types, the checker, boot, the runtime or the approver keeps, a
246
252
  - Data work first, external calls after. The invocation's one transaction rolls back on a throw; IAM, object storage
247
253
  and connector calls do not. An endpoint the generated tests cover calls no outside service (a provider's API): it
248
254
  records an outbox row, and a job makes the call (`references/jobs.md`).
249
- - An endpoint never sends e-mail; it has no `ctx.mail`. It records an outbox row and declares `then: '<job>'`, which
255
+ - An endpoint never sends e-mail; it has no `ctx.mail`. It records an outbox row, declares `kicks: ['<job>']` and calls `ctx.kick('<job>')`, which
250
256
  starts that job once its transaction committed. The job declares `mailer` and sends with `ctx.mail`, under a key
251
257
  derived from the row, and sends again only where `ctx.mail.mayRetry(error)` allows it (`references/jobs.md`).
252
258
  - No `as` (except `as const`), `any`, `@ts-ignore`, `@ts-expect-error`, `process.env` or `Bun.env`.
@@ -259,7 +265,7 @@ Which promise the types, the checker, boot, the runtime or the approver keeps, a
259
265
  - A 401, a 403 or a failing generated test: never loosen the contract, a role, a policy or a test to pass. Tell the
260
266
  human which action or role the caller lacks.
261
267
  - When the task does not already ask for it, before you add a role, a grant, an action, a declared deviation, an
262
- anonymous endpoint (`c.form`, a form's `files`, a webhook, a link), an export or an import, or a job, or before you drop or rename a
268
+ anonymous endpoint (a public procedure, its file field, a webhook, a link), an export or an import, or a job, or before you drop or rename a
263
269
  column: say in one sentence what it lets someone do, and wait for a yes. A data repair goes to the operator, not
264
270
  into a job.
265
271
  - A file refused as protected: stop and ask. Do not move the change elsewhere.
@@ -308,19 +314,21 @@ and the application's jobs sent. Write the seed and use it as `references/dev.md
308
314
  Read the one you need before you write that part. Together they are one application to copy from: `app-setup.md`
309
315
  (`package.json`, `tsconfig.json`, the contract, `fabrika.config.ts`, the SPA's build), `spa.md` (the hooks, forms and
310
316
  their errors, the caller, the components, routing, theming, screen tests), `table-and-migration.md`, `endpoint.md`
311
- (access forms, which `over`, the caller's scopes, handles, lists and aggregates, `fromRow`, `ctx.sql`, audited CSV
312
- downloads, members and labels), `tests.md` (generated tests, seeds, `testKit`, a reused scope, a caller's label, confirming a file ran),
317
+ (access forms, several capabilities, which `over`, the caller's scopes, handles, lists and aggregates, `fromRow`,
318
+ `ctx.sql`, audited CSV downloads, `admission` and `limits`, the HTTP shape and `redirect()`, signed tokens, `egress`,
319
+ `ctx.url`, a mount's `compatibility`, another origin, members and labels), `tests.md` (generated tests, seeds, `testKit`, a reused scope, a caller's label, confirming a file ran),
313
320
  `errors.md` (checker findings, type errors, runtime answers), `dev.md` (the seed and the preview), `jobs.md` (which dimension `ctx.within` names, the UTC
314
- day, e-mail through `ctx.mail`, the outbox and `then`), `files.md` and `webhooks-links-connectors.md`, all under `references/`. Two are applications of their own:
321
+ day, queue tables and `claim`, e-mail through `ctx.mail` with attachments and unsubscribe, the outbox and `kicks`),
322
+ `files.md` and `webhooks-links-connectors.md`, all under `references/`. Two are applications of their own:
315
323
  `start.md`, a new application before its first table (its first test, adding the first table, installing and updating
316
- this skill), and `public-intake.md`, a public page, a form, a job that issues links and e-mails them, a link holder's
317
- files, HR's review with its history.
324
+ this skill), and `public-intake.md`, a public page, public procedures, a job that claims their rows, issues links and
325
+ e-mails them, a link holder's files, HR's review with its history.
318
326
 
319
327
  ## Not built yet
320
328
 
321
329
  - The data layer runs on Bun and Postgres only (Zerops). On Cloudflare an application has no tables, jobs or files.
322
- - Jobs are cron only and app-wide: no `ctx.jobs.enqueue`, no job per scope. A mutation's `then` starts a run of its
323
- job; it carries nothing. A list write is off the road (`sql`).
324
- - `ctx.mail` sends no attachments.
330
+ - Jobs are cron only and app-wide: no `ctx.jobs.enqueue`, no job per scope. A `ctx.kick` starts a run of its job; it
331
+ carries nothing, and the job finds its work in a queue table with `claim`. A list write is off the road (`sql`).
332
+ - A read across tables (a join, a filter over a related table, an aggregate over several) is off the road (`sql`).
325
333
  - An MCP workspace has no shell, so `codegen` and `test-scaffold` cannot run there. Tell the human; never write a
326
334
  generated file by hand.
@@ -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.5.5",
23
- "@fabrika/db": "0.5.5",
24
- "@fabrika/provider-zerops": "0.5.5",
25
- "@fabrika/proxy-core": "0.5.5",
26
- "@fabrika/ui": "0.5.5",
22
+ "@fabrika/app": "0.7.0",
23
+ "@fabrika/db": "0.7.0",
24
+ "@fabrika/provider-zerops": "0.7.0",
25
+ "@fabrika/proxy-core": "0.7.0",
26
+ "@fabrika/ui": "0.7.0",
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.5.5",
34
- "@fabrika/cli": "0.5.5",
35
- "@fabrika/platform": "0.5.5",
33
+ "@fabrika/check": "0.7.0",
34
+ "@fabrika/cli": "0.7.0",
35
+ "@fabrika/platform": "0.7.0",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "latest",
38
38
  "@types/react": "^19.0.0",
@@ -50,25 +50,38 @@ the SPA is added with it.
50
50
  - `kysely` is there for the generated schema's types. The SPA reports its errors to Operations by itself:
51
51
  `FabrikaProvider` reads the DSN, release and environment the framework serves at `<prefix>/__fabrika-config.json`
52
52
  and sends every uncaught error and unhandled rejection there; add no error-reporting SDK. The same file carries
53
- `public: { <VARIABLE>: <value> }`: each form challenge's `siteKey`, and the variables the SPA mount lists in
54
- `c.spa(prefix, { …, public: ['SIGNUP_URL'] })`. A page reads a per-environment public value (a site key, a link
53
+ `public: { <VARIABLE>: <value> }`: each public procedure's challenge `siteKey`, and the variables the SPA mount lists in
54
+ `c.spa(prefix, { …, browserConfig: ['SIGNUP_URL'] })`. A page reads a per-environment public value (a site key, a link
55
55
  target) there, never as a build-time constant; it holds no other variable, and the contract refuses a secret in it.
56
56
  `react`, `react-dom` and `@fabrika/ui` (with `@types/react`, `@types/react-dom` and `@happy-dom/global-registrator`
57
57
  for the screen tests) are the SPA's: add them with it.
58
58
  - After you create a new workspace in a monorepo, run `bun install` at the repository root. Until then every
59
59
  `@fabrika/*` import fails and tsc reports a cascade of implicit `any`s that are not your bugs.
60
60
  - To try unreleased packages outside the fabrika-platform repository, run `bun scripts/release.ts pack --local
61
- --artifacts=<dir>` there. It writes the tarballs, `release-manifest.json` and `install.json`. Copy the tarballs into
62
- the application's `vendor/`, replace every `@fabrika/*` entry of `dependencies` and `devDependencies` with the
63
- `dependencies` of `install.json`, add its `overrides`, and run `bun install`. Then run the checker as
64
- `fabrika-check --packed <dir>/release-manifest.json`: without the flag it refuses the tarballs. A deployed
65
- application never takes this form; the platform build installs from the registry.
61
+ --artifacts=<dir>` there. It writes the tarballs, `release-manifest.json` and `install.json`.
62
+ 1. Copy the tarballs into the application's `vendor/`.
63
+ 2. Give each `@fabrika/*` entry the application already lists its `file:vendor/…` value from the `dependencies` of
64
+ `install.json`, in the field where it is: `@fabrika/check`, `@fabrika/cli` and `@fabrika/platform` stay in
65
+ `devDependencies`, the rest in `dependencies`. Add no other package of that list; `agent-canvas` and
66
+ `@fabrika/builder` are commands the application does not need.
67
+ 3. Add the `overrides` of `install.json` whole: each packed package names its `@fabrika/*` dependencies by a version
68
+ only the pack has.
69
+ 4. Run `bun install`, then `fabrika-check --packed <dir>/release-manifest.json`: without the flag the checker refuses
70
+ the tarballs.
71
+
72
+ Put `vendor/` in `.gitignore`, and commit neither the tarballs nor the `file:` form of `package.json` and `bun.lock`
73
+ to a branch that deploys. The platform build installs from the registry and runs the checker without `--packed`, so
74
+ it refuses that form.
66
75
  - The local pack is the one way to run a platform checkout's packages in an application. Never `bun link` a
67
76
  `@fabrika/*` package: a linked package resolves its dependencies and peers from the checkout, so the application
68
77
  runs a second React (every hook fails), a second zod and a second kysely. Installed from `vendor/`, the packages
69
78
  resolve `react`, `zod` and `kysely` from the application's own `node_modules`. The pack's version names the
70
- checkout's commit: after a change in the platform, commit it, pack again, and replace the block with the new
71
- `install.json`.
79
+ checkout's commit: after a change in the platform, commit it, pack again, replace the tarballs in `vendor/`, and
80
+ replace the block with the new `install.json`. `bun.lock` still names the old tarballs, and `bun install` then fails
81
+ with `ENOENT extracting tarball`. Before it, delete each line of `bun.lock` that names `vendor/`
82
+ (`grep -v 'vendor/' bun.lock > bun.lock.next && mv bun.lock.next bun.lock`): each is one entry of a packed package,
83
+ and `bun install` writes them again from `package.json`. Deleting the whole `bun.lock` also works, but it resolves
84
+ every other package again.
72
85
 
73
86
  ```json tsconfig.json
74
87
  {
@@ -138,30 +151,35 @@ export default serve(contract, { database: scoped(tables) })
138
151
  ```
139
152
 
140
153
  - Mounts: `c.rpc(prefix, groups)` once per prefix, `c.spa(prefix, { dir, access })`, `c.health('/healthz')` (required
141
- on Zerops), `c.form(path, { input, appendTo, then? })` (an anonymous form that appends one row and runs no handler), a
142
- webhook, a link mount. An SPA's `access` is `{ member: true }`, `{ action, over }`, or `{ public: true }` for a page
154
+ on Zerops), a webhook, a link mount. An anonymous visitor reaches a public procedure of an `c.rpc` mount
155
+ (`access: { public: true }`, `references/public-intake.md`). An SPA's `access` is `{ member: true }`, `{ action, over }`, or `{ public: true }` for a page
143
156
  anyone opens without signing in (a landing page, a public form): the approver sees it as "for anyone". A public SPA
144
157
  at `/` answers every `GET` no more specific endpoint serves; give a member SPA its own prefix (`/app`). Sections:
145
158
  `files`, `jobs`, `exports`, `csp` (`connectSrc`, `imgSrc`, `frameAncestors`, `scriptSrc`, `frameSrc`, `styleSrc`,
146
159
  `fontSrc`: exact `https://` origins, each with a `purpose`, at most 16 each). A hosted typeface needs both its
147
160
  stylesheet origin in `styleSrc` and its font origin in `fontSrc`; font files under the SPA's `dir` need neither. No path under `/__fabrika`.
148
- - A public form's abuse control, all optional on `c.form`: `rate: { perClient, perForm }` (per minute, shared by
149
- every instance; 10 and 100 by default; 429 past either), `honeypot: '<field>'` (a `z.optional(z.string())` input
150
- field the page hides; a filled one gets the same answer and writes nothing) and
151
- `challenge: { provider: 'turnstile', secret: 'TURNSTILE_SECRET', siteKey: 'TURNSTILE_SITE_KEY' }`. A challenge needs the input field
152
- `challengeToken: z.string()`, which the page fills from the Turnstile widget rendered with `action` set to the
153
- form's mount name; the framework verifies it on the server (a failed token 400, an unreachable provider 503, no
154
- row either way) and adds Turnstile's origin to the CSP. `secret` names an application variable the human sets,
155
- never the value; without it the application does not boot. `siteKey` names the variable with the widget's public
156
- site key, which every SPA mount serves its pages (below). Neither field reaches the inbox, so neither is a
157
- column. On Zerops every visitor still shares one client address (the balancer), so `perClient` acts as a second
158
- per-form limit: raise it, and use the challenge. `references/public-intake.md` builds a whole public intake on it.
159
- - `then: '<job>'` on `c.form` names a cron job of the same contract (`jobs`), which each accepted submission kicks
160
- once its row committed: a run starts within seconds, under the job's own key, and the answer stays the same constant
161
- and never waits for it. A mutation declares the same `then` for the outbox row it records (`references/jobs.md`). Kicks coalesce on the job's lease (at most one run and one pending, however many visitors
162
- submit), so the job still drains its inbox in batches under its own budgets. Keep its schedule: it is the recovery
163
- path for a kicked run that died. A kick carries nothing; the job reads its inbox. `kit.runJob` first waits for the
164
- kicked runs.
161
+ - A public procedure's abuse control is its `admission`: `rate: { perClient, perEndpoint }` (per minute, shared by
162
+ every instance; 10 and 100 by default for a public mutation; 429 past either) and
163
+ `challenge: { provider: 'turnstile', secret: 'TURNSTILE_SECRET', siteKey: 'TURNSTILE_SITE_KEY' }`. `honeypot(value)`
164
+ of `@fabrika/app` is true for a filled field the page hides: answer the same and write nothing. A challenge needs the
165
+ input field `challengeToken: z.string()`, which the page fills from the Turnstile widget rendered with `action` set
166
+ to the endpoint id with `_` for `.` (`api_visitors_register`), or to the challenge's own `action: 'register'` (at most
167
+ 32 letters, digits, `_` and `-`, one per procedure); the framework verifies it on the server (a failed or replayed
168
+ token 400 `challenge_failed`, an unreachable provider 503, no row either way) and adds Turnstile's origin to the CSP. `secret` names an
169
+ application variable the human sets, never the value; without it the application does not boot. `siteKey` names
170
+ the variable with the widget's public site key, which every SPA mount serves its pages (below). On Zerops every
171
+ visitor still shares one client address (the balancer), so `perClient` acts as a second per-endpoint limit: raise
172
+ it, and use the challenge. `references/public-intake.md` builds a whole public intake on it.
173
+ - `defineApp(vocabulary, tables, { imports?, tokens? })`: `imports` names other applications' exports
174
+ (`references/webhooks-links-connectors.md`), `tokens` the purposes of signed tokens (`references/endpoint.md`).
175
+ - `c.rpc('/api/v1', groups, { compatibility: 'backward' })` promises a mount's callers a stable API
176
+ (`references/endpoint.md`).
177
+ - `kicks: ['<job>']` on a mutation names cron jobs of the same contract (`jobs`), and `ctx.kick('<job>')` in its
178
+ handler starts one once its transaction committed: a run starts within seconds, under the job's own key, and the
179
+ answer never waits for it (`references/jobs.md`). Kicks coalesce on the job's lease (at most one run and one
180
+ pending, however many calls arrive), so the job still works in batches under its own budgets. Keep its schedule: it
181
+ is the recovery path for a kicked run that died. A kick carries nothing; the job reads its rows. `kit.runJob` first
182
+ waits for the kicked runs.
165
183
  - `src/app.ts` composes and does nothing else. A dependency built from the application's own variables is
166
184
  `defineDeps(build)`, passed as `serve(contract, { database, deps })` and read in a handler with `get()`. Never read
167
185
  `process.env`. `build` runs at every boot, the generated tests' too, which pass no variable: read a variable when
@@ -59,8 +59,8 @@ export default seed
59
59
  - `dev/dev.env` holds the application's variables for `app dev`, one `NAME=value` per line (`#` comments, quotes
60
60
  optional). They win over the shell's; a `FABRIKA_*` name is refused. Commit it: it holds stand-in values, never a
61
61
  real secret.
62
- - A form's challenge passes in `app dev`: the stand-in answers Turnstile's siteverify for any token. Set the form's
63
- `secret` variable to any value in `dev/dev.env`, and send a fresh `challengeToken` with each submission, as a test
62
+ - A public procedure's challenge passes in `app dev`: the stand-in answers Turnstile's siteverify for any token. Set the
63
+ challenge's `secret` variable to any value in `dev/dev.env`, and send a fresh `challengeToken` with each submission, as a test
64
64
  does: a token is single use. For the widget on a page, set the `siteKey` variable to Turnstile's always-passing test
65
65
  key `1x00000000000000000000AA`. Its token is the same every time, and `app dev` admits that one token again at each
66
66
  submission; a deployed application records it like any other, so there it passes once.
@@ -72,7 +72,7 @@ export default seed
72
72
  - The seed's second argument reads what left the application: `outside.mails()` is every message a job handed to the
73
73
  stand-in, oldest first (`{ id, at, to, subject, text, html, key, transportKey, answer, outcome, delivered, … }`), so
74
74
  the seed can open a link the application mailed. Run the job first (`dev.runJob('<job>')`), or let a mutation's
75
- `then` kick it and `await dev.drain()`.
75
+ `ctx.kick` start it and `await dev.drain()`.
76
76
  - Point another outside service at the dev outbox: `app dev` captures every request the process sends to
77
77
  `https://outbox.invalid` (`DEV_OUTBOX_ORIGIN` of `@fabrika/app/testing`) and answers `200 {"id": …}`. A section of
78
78
  its own lists each one, by the `to` and `subject` of a JSON body, and opens its body; its headers are not kept.
@@ -95,5 +95,5 @@ ARCHIVE_KEY=dev
95
95
  is the invited person from then on. A job's message opens at `/__fabrika/dev/mail/<id>`.
96
96
  - The choice stays in a cookie the application never sees. The data is gone when the command stops.
97
97
  - It serves this machine only (`--host` is a loopback address, `--port=0` picks a free port), answers no request
98
- addressed to another host name, and runs no cron job on its schedule: the seed runs one with `dev.runJob`, a test with `kit.runJob`. A form's or a
99
- mutation's `then` still starts its job, as deployed.
98
+ addressed to another host name, and runs no cron job on its schedule: the seed runs one with `dev.runJob`, a test with `kit.runJob`. A mutation's
99
+ `ctx.kick` still starts its job, as deployed.
@@ -19,6 +19,11 @@ export const Versioned = z.strictObject({
19
19
  })
20
20
  export const Cursor = z.string().max(500)
21
21
  export const OrgInput = z.strictObject({ orgId: z.string().min(1).max(100) })
22
+ export const WorkloadRow = z.strictObject({
23
+ assignee: z.nullable(z.string()),
24
+ open: z.int(),
25
+ overdue: z.int(),
26
+ })
22
27
  ```
23
28
 
24
29
  - `z` comes from `zod/v4`. Objects are `z.strictObject`, never `z.object`. No `transform`, `refine`, `pipe`,
@@ -50,9 +55,11 @@ export const OrgInput = z.strictObject({ orgId: z.string().min(1).max(100) })
50
55
  | `{ member: true }` | any member, no application data | nothing | nothing |
51
56
 
52
57
  - Every endpoint also has `ctx.db.reference.<table>` (reference tables), `ctx.principal`
53
- (`{ kind: 'user', id, label, email }` for a `human` caller), `ctx.requestId` and `ctx.signal`, which aborts when
54
- the client leaves or the request runs past its budget (10 s); its transaction then rolls back. Pass it to every
55
- outbound call. An `on`, `in` or `over` endpoint also reads `ctx.db.app.<table>`: each application table whose read
58
+ (`{ kind: 'user', id, label, email }` for a `human` caller), `ctx.requestId`, `ctx.client` and `ctx.signal`, which
59
+ aborts when the client leaves or the request runs past its time limit (`limits.timeoutMs`, 10 s by default); its
60
+ transaction then rolls back. Pass it to every outbound call. `ctx.client` is `{ ip, userAgent }` as the proxy saw
61
+ them: record it (consent evidence), never decide on it. `ip` is `null` where the platform cannot vouch for the
62
+ address (behind the Zerops balancer), and `userAgent` is cut to 256 characters. An `on`, `in` or `over` endpoint also reads `ctx.db.app.<table>`: each application table whose read
56
63
  action is the endpoint's action, every row, read-only (below).
57
64
  - `ctx.principal.email` is the mailbox IAM recorded for the person, lower-cased, from the token: the address to mail,
58
65
  not a proof that the person controls it. Mail "send it to me"
@@ -64,8 +71,8 @@ export const OrgInput = z.strictObject({ orgId: z.string().min(1).max(100) })
64
71
  key (`references/jobs.md`). A call that is the request's whole purpose lives in a `{ member: true }` endpoint, which
65
72
  they do not cover. The framework's own calls (IAM, the file store, `ctx.connect`) are not outside.
66
73
  - **An endpoint never sends e-mail**, covered or not: it has no `ctx.mail`. It records the message in an outbox row
67
- and declares `then: '<job>'` on the mutation, so the job that declares `mailer` sends it within seconds of the
68
- commit (`references/jobs.md`; the test e-mail in `references/public-intake.md`). Only a mutation declares `then`.
74
+ and declares `kicks: ['<job>']` on the mutation and calls `ctx.kick('<job>')`, so the job that declares `mailer`
75
+ sends it within seconds of the commit (`references/jobs.md`; the test e-mail in `references/public-intake.md`).
69
76
  - `ctx.principal.label` is a `string`, never `null`: the label IAM holds for the person (the one its directory
70
77
  shows), or the id when the token carries none. When the caller owns what it writes (the creator of a deal, the
71
78
  assignee of a task nobody was picked for), store `ctx.principal.id` and `ctx.principal.label`, never a
@@ -110,6 +117,35 @@ dimension value takes `access: { action, app: true }`.
110
117
  keep their own forms.
111
118
  - The approver sees it as "app-wide: a global grant", and a change of an endpoint to or from `app: true` widens.
112
119
 
120
+ ### Several capabilities: `capabilities`
121
+
122
+ An operation that reads one object and writes another (a copy from one deal to another, a handover between two
123
+ records) needs two capabilities. Declare each by name instead of reaching off the road with `sql` or `unscoped`:
124
+
125
+ ```text
126
+ copyToTask: r.mutation({
127
+ input: z.strictObject({ id: z.uuid(), fromTaskId: z.uuid(), toTaskId: z.uuid() }),
128
+ output: Saved,
129
+ access: {
130
+ capabilities: {
131
+ from: { action: 'tasks.read', on: 'task', from: 'fromTaskId' },
132
+ to: { action: 'tasks.write', on: 'task', from: 'toTaskId' },
133
+ },
134
+ },
135
+ }).handle(async ({ input, ctx }) => {
136
+ const source = await ctx.db.on(ctx.capabilities.from).task_attachments.get(input.id)
137
+ return ctx.db.on(ctx.capabilities.to).task_attachments.insert({ file: source.file, name: source.name })
138
+ })
139
+ ```
140
+
141
+ - At least two, each `{ action, on, from }`, `{ action, in, from }` or `{ action, over }`. `creates`, `app` and
142
+ `member` are single forms only.
143
+ - The proxy checks every requirement. The framework mints them in the declared order, and the first refusal answers
144
+ (`on` 404, `in` and `over` 403).
145
+ - The handler binds each with `ctx.db.on(…)`, `ctx.db.in(…)` or `ctx.db.over(…)`. It has no `ctx.target`,
146
+ `ctx.within`, `ctx.db.app`, `ctx.members` or `ctx.links`.
147
+ - The generated tests add a denied case per capability. A join or a lock over many rows still needs `sql`.
148
+
113
149
  ### The methods of each handle
114
150
 
115
151
  | Handle | Methods |
@@ -122,7 +158,7 @@ dimension value takes `access: { action, app: true }`.
122
158
  | `app.<table>` (`app: true`), an application table | `list(options)`, `count({ where })`, `aggregate(spec)`, `get(id)`; in a mutation `getForUpdate(id)`, `insert(values)`, `update(id, values, { expectVersion })`, `remove(id, { expectVersion })` |
123
159
  | `app.<table>` (`app: true`), a scoped table | `list(options)` (read action or `appRead`), `count({ where })` (also `appCount`), `aggregate(spec)` |
124
160
  | `app.<table>` (`on`, `in`, `over`) | an application table whose read action is the endpoint's: `list(options)`, `count({ where })`, `aggregate(spec)`, `get(id)`; no write |
125
- | `unscoped.<table>` (declared) | `list`, `count`, `aggregate`, `get(id)` (not of a reference table or an inbox, which have no key); with `'write'` also `insert`, `update`, `remove` |
161
+ | `unscoped.<table>` (declared) | `list`, `count`, `aggregate`, `get(id)` (not of a reference table, which has no key); with `'write'` also `insert`, `update`, `remove` |
126
162
 
127
163
  `{ expectVersion }` is taken only on a table with `version`.
128
164
 
@@ -341,13 +377,15 @@ Mount every group in `fabrika.contract.ts`:
341
377
  ## Shared data access: `queries.ts`
342
378
 
343
379
  A function of the slice's `queries.ts` takes the handle as a parameter typed with `DbHandle` and returns plain values.
344
- It decides no authorization: the capability already bounds the handle.
380
+ It decides no authorization: the capability already bounds the handle. `queries.ts` exports only functions: a schema
381
+ that the contract also reads (`WorkloadRow`, an output) lives in `src/schemas.ts`, or the contract would load
382
+ `queries.ts` and refuse its functions (`literal`).
345
383
 
346
384
  ```ts src/reports/queries.ts
347
385
  import type { DbHandle, SqlHandle } from '@fabrika/app/contract'
348
386
  import type { Ref } from '@fabrika/app/implement'
349
- import { z } from 'zod/v4'
350
387
  import type { app } from '../app-kit'
388
+ import { WorkloadRow } from '../schemas'
351
389
 
352
390
  export const statusCounts = async (
353
391
  within: DbHandle<typeof app, 'over', 'project', 'tasks.read'>,
@@ -358,12 +396,6 @@ export const statusCounts = async (
358
396
  return { open: count('open'), doing: count('doing'), done: count('done') }
359
397
  }
360
398
 
361
- export const WorkloadRow = z.strictObject({
362
- assignee: z.nullable(z.string()),
363
- open: z.int(),
364
- overdue: z.int(),
365
- })
366
-
367
399
  export const workloadRows = (
368
400
  sql: SqlHandle<typeof app, 'read'>,
369
401
  org: Ref<'org', 'tasks.read'>,
@@ -395,8 +427,8 @@ Raw SQL is legitimate for a join, a report, an `OR` or `IS NULL` filter, or a bu
395
427
  import { attachment } from '@fabrika/app/contract'
396
428
  import { z } from 'zod/v4'
397
429
  import { app } from '../app-kit'
398
- import { TaskStatus } from '../schemas'
399
- import { statusCounts, WorkloadRow, workloadRows } from './queries'
430
+ import { TaskStatus, WorkloadRow } from '../schemas'
431
+ import { statusCounts, workloadRows } from './queries'
400
432
 
401
433
  const Workload = z.strictObject({
402
434
  orgId: z.string().min(1).max(100),
@@ -461,8 +493,8 @@ export const reports = app.group({ callers: ['human'] }, (r) => ({
461
493
  values, each container grant on its own column, every row for a global grant and none for an empty one. The
462
494
  capability is the access form's: `ctx.target` for `on` and `in`, `ctx.within` for `over`; a job passes the `Ref` or
463
495
  `Within` it minted to `tx.sql.scope`. A table the capability does not reach is a type error at the table name. A
464
- reference table is shared by every scope and needs no fragment, and no statement writes it. A form's inbox needs none either; read it from a site that holds the inbox's
465
- `read` action (the intake job of `references/public-intake.md`).
496
+ reference table is shared by every scope and needs no fragment, and no statement writes it. An application table
497
+ needs none either (the intake job of `references/public-intake.md`).
466
498
  - **A fragment carries its action into the statement.** On the table an `update` or `delete` writes it needs the
467
499
  table's own update or delete action (`ctx.within('tasks.write', 'project')` for `update tasks`), so a read action
468
500
  never scopes a write. A column's own write action (`columns: { assignee: { write: 'tasks.assign' } }`) scopes an
@@ -522,22 +554,117 @@ the invocation's transaction. `'write'` (a mutation, a job, a webhook) also inse
522
554
  then names the chain columns itself. Use it for an operator's cross-scope view, never to dodge a capability the caller
523
555
  should hold. The generated tests skip the other-scope leak case for such an endpoint.
524
556
 
525
- ## Another origin: `crossOrigin`
557
+ ## Admission and limits
558
+
559
+ Every procedure takes `admission` (who may send it, how often) and `limits` (how large, how long):
560
+
561
+ | Option | Credentialed and link procedures | Public procedures (`references/public-intake.md`) |
562
+ | ----------------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------- |
563
+ | `admission.rate` (calls per minute) | `perCaller`, `perEndpoint`; none by default | `perClient`, `perEndpoint`, `bytesPerHour`; a mutation 10 and 100 |
564
+ | `admission.origins` | `'same-origin'` (default) or `'listed'` | `'same-origin'`, `'listed'` or `'any'` |
565
+ | `admission.challenge` | — | `{ provider: 'turnstile', secret, siteKey, action? }` |
566
+ | `limits.maxBytes` | 64 KiB, at most 1 MiB; with file fields 64 KiB plus each file at its kind's `maxBytes`, at most 48 MiB | the same |
567
+ | `limits.timeoutMs` | 10 s, at most 60 s | the same |
568
+
569
+ - A declared `rate` merges into the default; `null` turns one bucket off. Past a bucket the answer is 429.
570
+ - Every bucket is charged before the body is read and stays charged whatever the handler does. A bucket is a database
571
+ write per call, so a query declares one only where it needs one.
572
+ - The body must arrive within `max(30 s, maxBytes at 16 KiB/s)`: a stalled body is 408. The handler's time starts
573
+ after the last byte, so a slow upload still commits.
574
+ - The approver sees every effective value. A higher bucket, a dropped challenge or a longer time limit widens.
575
+
576
+ ## Another origin: `admission.origins: 'listed'`
526
577
 
527
578
  A page on another site (a partner's page, a separate marketing site, another application in the browser) calls an RPC
528
- endpoint only when the endpoint says `crossOrigin: true` beside its `access`, and an operator lists that page's origin
529
- for the environment in Control (`--cross-origin=https://partner.example` on `fabrika control apps environments put`). Both halves are
530
- needed: the contract says which endpoints, the environment says from where, so staging and production can list
531
- different pages. The approver sees the flag like any other access change.
579
+ procedure only when it declares `admission: { origins: 'listed' }`, and an operator lists that page's origin for the
580
+ environment in Control (`--cross-origin=https://partner.example` on `fabrika control apps environments put`). Both
581
+ halves are needed: the contract says which procedures, the environment says from where, so staging and production can
582
+ list different pages. The approver sees the change like any other access change.
532
583
 
533
584
  - **A key, never a session.** The page sends `Authorization: Bearer px_…` (a machine key, or a person's own key). A
534
585
  person's session is never read for a request from a listed origin: the answer is 401 without a login bounce.
535
586
  - **The proxy answers CORS, the handler never does.** The proxy answers the preflight and sets
536
587
  `Access-Control-Allow-Origin` on the answer; it deletes every `Access-Control-*` header a handler sets.
537
- - **Only RPC procedures.** A link procedure, an export, an SPA, a download and an upload refuse `crossOrigin`. A form
538
- takes it too (`references/public-intake.md`).
588
+ - **Only RPC procedures.** A link procedure, an export, an SPA and a webhook have no other origin. A public procedure
589
+ may also declare `'any'` (`references/public-intake.md`).
539
590
  - **Test it** with `testKit(app, { crossOrigins: ['https://partner.example'] })` and a request carrying that `Origin`.
540
591
 
592
+ ## The HTTP shape: `http`
593
+
594
+ A procedure is `POST <prefix>/<group>.<procedure>` with a JSON body by default. `http: { method?, query?, body?,
595
+ cache?, path? }` changes the wire, never what the handler does:
596
+
597
+ - `method: 'GET'` on a query reads its input from flat scalar URL parameters (a number as written, `true`/`false`,
598
+ `null`; a name twice is refused). A query whose output is a file is `GET` by default. A session mutation is never
599
+ `GET`.
600
+ - `query: z.strictObject({ … })` on a `POST` reads a flat schema from the URL beside the body; the handler gets it as
601
+ `query`, beside `input`.
602
+ - `body: 'urlencoded'` takes a native `<form>` post of flat scalar fields. A file field makes the body multipart by
603
+ itself: its JSON input part first, then the files.
604
+ - `cache: { maxAgeSeconds }` or `cache: 'immutable'` on a public `GET` query lets a browser or a CDN reuse a
605
+ successful answer (`Cache-Control: public, …`). Every other answer is `no-store`. `'immutable'` needs an input that
606
+ names a value that never changes (a file id), so a new value changes the URL.
607
+ - `path: '/register'` on a public procedure serves it at that literal path instead, for a caller you do not ship: a
608
+ page on another origin, or a URL published elsewhere (a 0.5 form's). It lies outside every mount's prefix (the root
609
+ SPA's excepted) and under no `/__fabrika`; the endpoint id and the typed client's call stay the same. Any other
610
+ procedure takes no `path`: its caller is your own page, through the typed client.
611
+ - `api.<group>.<procedure>.url(input)` is the URL of a `GET` procedure for a same-origin page.
612
+
613
+ **A redirect.** `output: redirect({ origins? })` (`@fabrika/app/contract`): the handler returns `{ to }`, and the
614
+ framework answers 303 after a `POST` and 302 after a `GET`. A target is a path of the application's own origin, a URL
615
+ of an origin `origins` lists, or a URL a verified signed token of this request carries; anything else is a 500 and a
616
+ log line, never an open redirect. A native form then posts, redirects and loads the next page. The typed client gets
617
+ `{ to }` instead and decides whether to navigate.
618
+
619
+ ## Signed tokens: `signs` and `signedToken`
620
+
621
+ A token is a signed, single-purpose value in a URL: an unsubscribe link, a tracked click, the link that manages one
622
+ booking. It is no credential and opens no mount. Declare each purpose once, in the kit:
623
+
624
+ ```text
625
+ export const app = defineApp(vocabulary, tables, {
626
+ tokens: { unsubscribe: token(Recipient, { maxDays: 400 }) },
627
+ })
628
+ ```
629
+
630
+ - `token(Claims, { maxDays })` (`@fabrika/app/contract`): the claims are a strict object, `maxDays` the longest
631
+ lifetime.
632
+ - A site that issues tokens declares `signs: ['<purpose>']` and calls
633
+ `ctx.tokens.sign('<purpose>', claims, { days })`, `days` at most `maxDays`. An RPC procedure, a job and a webhook
634
+ sign; an export and a link procedure never do.
635
+ - `signedToken('<purpose>')` is a top-level field of an input or of `http.query`. The framework verifies it as it
636
+ parses the input (a bad or expired token is 400), and the handler gets the typed claims.
637
+ - A public mutation whose input holds a required `signedToken` may be `http: { method: 'GET' }` and answer
638
+ `redirect()` or a schema. The approver reads "changes state on GET; link scanners may trigger it". A page that
639
+ needs certainty keeps a `POST` behind a button.
640
+ - A public procedure updates or removes a row only by an id a verified token of its request carries, with
641
+ `expectVersion`.
642
+ - Single use and revocation are your state (a row the token names). The keys are the platform's: Control generates
643
+ and rotates them, and an old key verifies until its last token expires. Cloudflare has no signed tokens.
644
+
645
+ ## Outside calls: `egress`
646
+
647
+ A credentialed procedure, a webhook or a job that calls an outside HTTPS API declares its origins:
648
+ `egress: ['https://api.example.com']`, exact origins, no path or wildcard, at most 16. `fetch` to any other origin is
649
+ refused while the site runs (`capability_refused: undeclared_egress`). A public, link or export procedure declares
650
+ none. The checker's `cross-app-fetch` accepts an absolute URL only under an origin some site declares. The approver
651
+ sees every origin. The guard stops mistakes; it is no sandbox.
652
+
653
+ ## Links to your own procedures: `ctx.url`
654
+
655
+ `ctx.url('<endpoint id>', { input?, query? })` builds an absolute URL of one of the application's own procedures, on
656
+ every site: the environment's public origin, the procedure's path, and the parameters its HTTP shape reads from the
657
+ URL (the input of a `GET`, the `query` of a `POST`). The endpoint id is `<mount>.<group>.<procedure>`
658
+ (`'api.mail.unsubscribe'`). The checker's `url-endpoint` refuses a literal id that names no procedure. Never build an
659
+ origin from a variable of your own.
660
+
661
+ ## A promise to integrations: `compatibility`
662
+
663
+ An integration calls RPC procedures with a service key (caller `machine`). Promise it a stable API with
664
+ `c.rpc('/api/v1', groups, { compatibility: 'backward' })`: Control then refuses a deploy that removes a procedure,
665
+ narrows its input, changes its output, its HTTP shape, its file answer or its access. A breaking change is a new mount
666
+ (`/api/v2`) beside the old one. The default is `'unchecked'`.
667
+
541
668
  ## Members
542
669
 
543
670
  `ctx.members` of an endpoint `on` or `in` a dimension reads the members of the nearest dimension, its own or one