@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.
- package/package.json +9 -9
- package/skill/SKILL.md +29 -21
- package/skill/references/app-setup.md +54 -36
- package/skill/references/dev.md +5 -5
- package/skill/references/endpoint.md +152 -25
- package/skill/references/errors.md +26 -23
- package/skill/references/files.md +187 -137
- package/skill/references/jobs.md +82 -35
- package/skill/references/public-intake.md +387 -236
- package/skill/references/spa.md +5 -5
- package/skill/references/start.md +2 -1
- package/skill/references/table-and-migration.md +24 -2
- package/skill/references/tests.md +8 -5
- package/skill/references/webhooks-links-connectors.md +65 -12
- package/src/bun.ts +3 -2
- package/src/client/contract.ts +184 -74
- package/src/client/index.ts +2 -1
- package/src/client/transport.ts +7 -2
- package/src/cloudflare.ts +2 -0
- package/src/contract/artifact-schema.ts +110 -15
- package/src/contract/build.ts +1184 -613
- package/src/contract/compile.ts +44 -10
- package/src/contract/connect.ts +71 -5
- package/src/contract/context.ts +132 -23
- package/src/contract/declaration.ts +78 -63
- package/src/contract/defaults.ts +64 -58
- package/src/contract/define.ts +76 -124
- package/src/contract/file.ts +78 -0
- package/src/contract/group.ts +244 -37
- package/src/contract/index.ts +18 -0
- package/src/contract/kit.ts +206 -126
- package/src/contract/platform.ts +8 -9
- package/src/contract/redirect.ts +49 -0
- package/src/contract/schema-from-shape.ts +7 -0
- package/src/contract/schema-shape.ts +10 -0
- package/src/contract/snapshot-source.ts +5 -0
- package/src/contract/token.ts +89 -0
- package/src/contract/webhook-binding.ts +26 -0
- package/src/contract/webhook-scheme.ts +32 -0
- package/src/files/handle.ts +22 -0
- package/src/files/kinds.ts +454 -0
- package/src/files/scan.ts +15 -9
- package/src/files/streams.ts +111 -0
- package/src/honeypot.ts +7 -0
- package/src/implement/anonymous.ts +18 -42
- package/src/implement/boot.ts +177 -159
- package/src/implement/bounded.ts +23 -0
- package/src/implement/browser-config.ts +4 -4
- package/src/implement/capability.ts +78 -2
- package/src/implement/{form-admission.ts → client-address.ts} +3 -38
- package/src/implement/dispatch.ts +810 -409
- package/src/implement/egress.ts +84 -0
- package/src/implement/faults.ts +11 -1
- package/src/implement/index.ts +3 -2
- package/src/implement/invocation.ts +135 -35
- package/src/implement/params.ts +88 -0
- package/src/implement/principal.ts +7 -0
- package/src/implement/public.ts +142 -0
- package/src/implement/tokens.ts +219 -0
- package/src/implement/types.ts +24 -3
- package/src/implement/urls.ts +78 -0
- package/src/implement/verifiers.ts +299 -0
- package/src/implement/webhook.ts +136 -55
- package/src/implement/workload.ts +16 -14
- package/src/index.ts +3 -0
- package/src/jobs/mail.ts +98 -10
- package/src/jobs/runner.ts +103 -71
- package/src/jobs/scrub.ts +4 -1
- package/src/jobs/types.ts +19 -2
- package/src/react/hooks.ts +2 -2
- package/src/react/provider.tsx +2 -2
- package/src/testing/dev.ts +2 -2
- package/src/testing/endpoint-access.ts +167 -128
- package/src/testing/kit.ts +79 -24
- package/src/testing/policy-isolation.ts +55 -29
- package/src/testing/proxy.ts +6 -2
- package/src/testing/sample-file.ts +30 -0
- package/src/client/files.ts +0 -135
- package/src/contract/file-schemas.ts +0 -25
- 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.
|
|
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.
|
|
97
|
-
"@fabrika/auth-core": "0.
|
|
98
|
-
"@fabrika/db": "0.
|
|
99
|
-
"@fabrika/email": "0.
|
|
100
|
-
"@fabrika/jwks": "0.
|
|
101
|
-
"@fabrika/platform": "0.
|
|
102
|
-
"@fabrika/platform-node": "0.
|
|
103
|
-
"@fabrika/proxy-core": "0.
|
|
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.
|
|
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
|
|
224
|
-
| ------------------------------ |
|
|
225
|
-
| a join, a report, a bulk write | `sql: 'read'` (or `'write'`)
|
|
226
|
-
| rows outside its capability | `unscoped: { <table>: 'read' }`
|
|
227
|
-
| another application's export | `connect: ['<import>']`
|
|
228
|
-
| a share link | `issues: '<link mount>'`
|
|
229
|
-
| e-mail (a cron job only) | `mailer: 'installation'`
|
|
230
|
-
| a file
|
|
231
|
-
|
|
|
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
|
|
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 (
|
|
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`,
|
|
312
|
-
|
|
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
|
|
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,
|
|
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
|
|
323
|
-
job
|
|
324
|
-
-
|
|
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.
|
|
23
|
-
"@fabrika/db": "0.
|
|
24
|
-
"@fabrika/provider-zerops": "0.
|
|
25
|
-
"@fabrika/proxy-core": "0.
|
|
26
|
-
"@fabrika/ui": "0.
|
|
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.
|
|
34
|
-
"@fabrika/cli": "0.
|
|
35
|
-
"@fabrika/platform": "0.
|
|
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
|
|
54
|
-
`c.spa(prefix, { …,
|
|
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`.
|
|
62
|
-
the application's `vendor
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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,
|
|
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),
|
|
142
|
-
|
|
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
|
|
149
|
-
every instance; 10 and 100 by default; 429 past either)
|
|
150
|
-
|
|
151
|
-
`
|
|
152
|
-
`challengeToken: z.string()`, which the page fills from the Turnstile widget rendered with `action` set
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
package/skill/references/dev.md
CHANGED
|
@@ -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
|
|
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
|
-
`
|
|
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
|
|
99
|
-
|
|
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
|
|
54
|
-
the client leaves or the request runs past its
|
|
55
|
-
outbound call.
|
|
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 `
|
|
68
|
-
commit (`references/jobs.md`; the test e-mail in `references/public-intake.md`).
|
|
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
|
|
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,
|
|
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.
|
|
465
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
needed: the contract says which
|
|
531
|
-
different pages. The approver sees the
|
|
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
|
|
538
|
-
|
|
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
|