@fabrika/app 0.3.2 → 0.4.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 (114) hide show
  1. package/README.md +78 -90
  2. package/package.json +68 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +364 -0
  5. package/skill/references/dev.md +67 -0
  6. package/skill/references/endpoint.md +627 -0
  7. package/skill/references/errors.md +108 -0
  8. package/skill/references/files.md +335 -0
  9. package/skill/references/jobs.md +169 -0
  10. package/skill/references/public-intake.md +1495 -0
  11. package/skill/references/spa.md +741 -0
  12. package/skill/references/start.md +179 -0
  13. package/skill/references/table-and-migration.md +264 -0
  14. package/skill/references/tests.md +305 -0
  15. package/skill/references/webhooks-links-connectors.md +190 -0
  16. package/src/agent-skill.ts +77 -0
  17. package/src/build.ts +53 -0
  18. package/src/bun.ts +302 -3
  19. package/src/client/caller.ts +24 -0
  20. package/src/client/contract.ts +309 -0
  21. package/src/client/files.ts +135 -0
  22. package/src/client/index.ts +19 -0
  23. package/src/client/rpc.ts +47 -0
  24. package/src/client/share.ts +14 -0
  25. package/src/{client.ts → client/transport.ts} +58 -61
  26. package/src/cloudflare.ts +139 -3
  27. package/src/contract/artifact-schema.ts +317 -0
  28. package/src/contract/attachment.ts +74 -0
  29. package/src/contract/build.ts +1936 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +437 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +156 -0
  35. package/src/contract/define.ts +567 -0
  36. package/src/contract/file-schemas.ts +25 -0
  37. package/src/contract/group.ts +263 -0
  38. package/src/contract/handled.ts +50 -0
  39. package/src/contract/index.ts +34 -0
  40. package/src/contract/kit.ts +607 -0
  41. package/src/contract/platform.ts +113 -0
  42. package/src/contract/problems.ts +30 -0
  43. package/src/contract/schema-from-shape.ts +152 -0
  44. package/src/contract/schema-shape.ts +407 -0
  45. package/src/contract/snapshot-source.ts +168 -0
  46. package/src/contract/vocabulary.ts +50 -0
  47. package/src/disk-assets.ts +25 -0
  48. package/src/fetch.ts +10 -0
  49. package/src/files/archive.ts +292 -0
  50. package/src/files/clamscan.ts +253 -0
  51. package/src/files/collections.ts +669 -0
  52. package/src/files/handle.ts +77 -0
  53. package/src/files/scan.ts +308 -0
  54. package/src/files/sniff.ts +70 -0
  55. package/src/files/treatment.ts +48 -0
  56. package/src/from-row.ts +53 -0
  57. package/src/implement/anonymous.ts +102 -0
  58. package/src/implement/attachment.ts +143 -0
  59. package/src/implement/boot.ts +808 -0
  60. package/src/implement/bounded.ts +72 -0
  61. package/src/implement/browser-config.ts +54 -0
  62. package/src/implement/capability.ts +439 -0
  63. package/src/implement/challenge.ts +115 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1085 -0
  66. package/src/implement/environment.ts +47 -0
  67. package/src/implement/failures.ts +116 -0
  68. package/src/implement/faults.ts +46 -0
  69. package/src/implement/form-admission.ts +120 -0
  70. package/src/implement/headers.ts +17 -0
  71. package/src/implement/implement.ts +81 -0
  72. package/src/implement/index.ts +60 -0
  73. package/src/implement/invocation.ts +346 -0
  74. package/src/implement/membership.ts +485 -0
  75. package/src/implement/principal.ts +99 -0
  76. package/src/implement/project.ts +76 -0
  77. package/src/implement/serve.ts +142 -0
  78. package/src/implement/types.ts +215 -0
  79. package/src/implement/webhook.ts +108 -0
  80. package/src/implement/workload.ts +160 -0
  81. package/src/implement/xlsx.ts +275 -0
  82. package/src/index.ts +12 -13
  83. package/src/jobs/report.ts +134 -0
  84. package/src/jobs/runner.ts +749 -0
  85. package/src/jobs/schedule.ts +129 -0
  86. package/src/jobs/types.ts +44 -0
  87. package/src/react/caller.tsx +60 -0
  88. package/src/react/errors.ts +82 -0
  89. package/src/react/hooks.ts +239 -0
  90. package/src/react/index.ts +31 -0
  91. package/src/react/provider.tsx +165 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/types.ts +40 -0
  94. package/src/rpc/dispatcher.ts +4 -19
  95. package/src/service/define.ts +121 -0
  96. package/src/service/index.ts +21 -0
  97. package/src/service/serve.ts +99 -0
  98. package/src/testing/caller.ts +123 -0
  99. package/src/testing/dev.ts +73 -0
  100. package/src/testing/endpoint-access.ts +806 -0
  101. package/src/testing/index.ts +23 -0
  102. package/src/testing/isolation.ts +25 -0
  103. package/src/testing/kit.ts +552 -0
  104. package/src/testing/membership.ts +747 -0
  105. package/src/testing/policy-isolation.ts +827 -0
  106. package/src/testing/preload.ts +6 -0
  107. package/src/testing/proxy.ts +87 -0
  108. package/src/testing/runner.ts +61 -0
  109. package/src/testing/status.ts +19 -0
  110. package/src/undumpable.ts +42 -0
  111. package/src/app.ts +0 -76
  112. package/src/middleware.ts +0 -38
  113. package/src/router.ts +0 -149
  114. package/src/testing.ts +0 -119
@@ -0,0 +1,305 @@
1
+ # references/tests.md — generated tests, seeds and your own tests
2
+
3
+ ## The generated tests
4
+
5
+ `bunx fabrika db test-scaffold` writes them from the contract and the table policies, and deletes the ones it no
6
+ longer writes. Commit them; never edit, format or import them (`generated-test-hash`).
7
+
8
+ - `src/db/__tests__/<table>.policy-isolation.test.ts`: the data layer keeps each table's rows in their scope.
9
+ - `src/__tests__/endpoint-access.test.ts`: every `on` and `in` endpoint refuses another scope's object (404 or 403);
10
+ every `on` and `over` endpoint, and every `in` endpoint that declares `sql`, answers nothing of another scope; a
11
+ caller with only an endpoint's write action, plus what IAM requires of it (below), reaches its own object (200). An
12
+ `app` or `creates` endpoint refuses a holder of its action in one scope only (403) and answers a global holder.
13
+ - Their kit stands in for IAM, as `testKit` does (below): it creates a scope, assigns or invites a member and issues a
14
+ link under IAM's rules for the caller's grants. A repeated invitation answers the open one, `resend` restarts its
15
+ 7 days, and an organization's address is refused for an external role (`internal_mailbox`). So the caller of an endpoint with the dimension's `members.manage`
16
+ holds it on the object itself, with one role the assigned role's `assignableBy` names and the assigned role's
17
+ actions: the membership roles the input names, or every one of the dimension when it names none. The caller of an
18
+ endpoint that `issues` also holds the actions its link grants.
19
+
20
+ Run them after every change of the contract or the tables. The platform runs them at every start, and the
21
+ application is not ready until each passes. A failing generated test is a bug in the handler, the policy or the
22
+ fixtures, never in the test: do not loosen access to make it pass. A case that throws instead of failing an
23
+ assertion names what the generator could not build: an input the schema refuses, an input the handler refused (400),
24
+ which the fixtures below supply, or a `SeedLimitError` of the seed (below). Do not reshape the contract or the schema
25
+ around one; ask the human.
26
+
27
+ ## Fixtures and seeds
28
+
29
+ `src/__tests__/endpoint-access.fixtures.ts` is yours, read statically (plain literals only):
30
+
31
+ ```ts src/__tests__/endpoint-access.fixtures.ts
32
+ export const seeds = { 'api.tasks.close': { status: 'doing' } }
33
+ export const members = { 'api.tasks.assign': ['assignee'] }
34
+ ```
35
+
36
+ - `export const fixtures = { '<endpoint id>': { … } }` is an input for an endpoint whose schema the scaffold cannot
37
+ sample, such as a `regex` or a webhook's bind:
38
+ `{ 'webhooks.github.bind': { scope: 'acme', account: 'installation:1' } }`. The checker names each missing one,
39
+ and one of an endpoint that does not exist (`endpoint-fixture`). The access form's `from` field is
40
+ replaced by the seeded scope.
41
+ - `members['<endpoint id>']` lists the input fields that name a picked person the handler checks with
42
+ `ctx.members.get` (`assign`'s `assignee`). The generated cases send a real member of the object's org there; without
43
+ it they send a sample, the handler answers 400, and the case fails.
44
+ - `invitations['<endpoint id>']` and `links['<endpoint id>']` list the input fields that name an invitation an endpoint
45
+ cancels (`ctx.members.revoke(ctx.target, { invitationId })`) or a share link it revokes (`ctx.links.revoke(id)`):
46
+ `export const invitations = { 'api.members.cancel': ['invitationId'] }`. The generated cases send one their stand-in
47
+ IAM holds there: an invitation open in the object's value, a link of the endpoint's mount that the case's caller
48
+ issued. Without it they send a sample, IAM answers 404, and the case fails.
49
+ - `seeds['<endpoint id>']` is the state its precondition needs, written into the row of the table that owns the
50
+ endpoint's dimension in both scopes: `close` answers 409 for an open task, so the seed makes it `doing`. A seed
51
+ sets neither a column that places the row nor a text column that holds a marker; a text column a CHECK lists the
52
+ values of, and a foreign key, it may set.
53
+ - `rows['<endpoint id>']` names the input fields that address a row lying in the endpoint's object, with that row's
54
+ table: `export const rows = { 'api.tasks.editComment': { commentId: 'task_comments' } }`. The generated cases seed
55
+ one row of the table in each scope's object and send its key there. The table lies directly in the object: a child
56
+ table of the table that owns it, or a table it contains; the checker refuses a row further down
57
+ (`endpoint-fixture`). Without it they send a sample, the handler
58
+ finds no row (404), and the case fails.
59
+ - The endpoint id is `<mount>.<group>.<endpoint>` (`api.tasks.close`).
60
+ - The seed of the tests fills every text column with a marker and every other required column with a sample of its
61
+ type: an enum's first label, `1`, `false`, today. A column with a `DEFAULT` keeps it (`status` is `open`). A column
62
+ a `CHECK` names gets the first value of an `IN (…)` list, or `null` where it is nullable. A foreign key outside
63
+ the containment chain is `null` where it is nullable, and otherwise references an existing or a seeded row of a
64
+ reference or application table. What the seed cannot satisfy (another `CHECK` on a required text column, a required
65
+ key to a dimension or child table outside the chain) fails the test with a `SeedLimitError` that names the
66
+ constraint: a limit of the generator, not a bug of the handler. Keep the column as the domain needs it, and ask the
67
+ human when the limit blocks the tests.
68
+ - A generated case's input is a sample of the schema: no optional field, `null` for a nullable one, an enum's first
69
+ value, the shortest string its bounds admit (`'x'`), the smallest number, `false`, a fixed value for a format
70
+ (`uuid`, `email`, `iso.date`). When that input meets a precondition (`close` of an `open` task is 409), seed the
71
+ state, or give the whole input in `fixtures`.
72
+
73
+ ## Your own tests
74
+
75
+ Test the rules of `logic.ts` as plain functions. Test a handler through `testKit` and the typed client, with each
76
+ allowed call beside its refused twin:
77
+
78
+ ```ts src/tasks/__tests__/tasks.test.ts
79
+ import { createClient } from '@fabrika/app/client'
80
+ import { statusOf, testKit } from '@fabrika/app/testing'
81
+ import { testDatabase } from '@fabrika/db/testing'
82
+ import { afterAll, describe, expect, test } from 'bun:test'
83
+ import { contract } from '../../../fabrika.contract'
84
+ import app from '../../app'
85
+
86
+ const postgres = await testDatabase({
87
+ migrations: new URL('../../../db/migrations/', import.meta.url),
88
+ label: 'tasks.test.ts',
89
+ })
90
+
91
+ if (postgres !== null) {
92
+ afterAll(() => postgres.close())
93
+ const kit = testKit(app, { database: postgres.database })
94
+ const acme = kit.scope('org', 'acme')
95
+ const inAcme = (role: 'owner' | 'member') =>
96
+ kit.testCaller([{ role, scope: acme }])
97
+ const owner = inAcme('owner')
98
+ const member = inAcme('member')
99
+ const ownerApi = createClient(contract, 'api', { fetch: kit.browser(owner) })
100
+ const memberApi = createClient(contract, 'api', { fetch: kit.browser(member) })
101
+
102
+ describe('tasks', () => {
103
+ test('an owner assigns a task and a member may not; the assignee sees it under mine', async () => {
104
+ const project = await ownerApi.projects.create({
105
+ orgId: 'acme',
106
+ name: 'Launch',
107
+ })
108
+ const task = await memberApi.tasks.create({
109
+ projectId: project.id,
110
+ title: 'Write the brief',
111
+ })
112
+ expect(
113
+ await statusOf(
114
+ memberApi.tasks.assign({
115
+ id: task.id,
116
+ expectVersion: 1,
117
+ assignee: member.id,
118
+ }),
119
+ ),
120
+ ).toBe(403)
121
+ expect(
122
+ await ownerApi.tasks.assign({
123
+ id: task.id,
124
+ expectVersion: 1,
125
+ assignee: member.id,
126
+ }),
127
+ ).toEqual({ id: task.id, version: 2 })
128
+ expect((await memberApi.tasks.mine({})).items.map((item) => item.id))
129
+ .toEqual([task.id])
130
+ })
131
+
132
+ test('only a task in progress closes', async () => {
133
+ const project = await ownerApi.projects.create({
134
+ orgId: 'acme',
135
+ name: 'Ops',
136
+ })
137
+ const task = await memberApi.tasks.create({
138
+ projectId: project.id,
139
+ title: 'Rotate keys',
140
+ dueOn: '2026-10-20',
141
+ })
142
+ expect(
143
+ await statusOf(memberApi.tasks.close({ id: task.id, expectVersion: 1 })),
144
+ ).toBe(409)
145
+ await memberApi.tasks.start({ id: task.id, expectVersion: 1 })
146
+ expect(await memberApi.tasks.close({ id: task.id, expectVersion: 2 }))
147
+ .toEqual({ id: task.id, version: 3 })
148
+ })
149
+
150
+ test("the member directory lists the org's members", async () => {
151
+ const listed = await memberApi.members.list({ orgId: 'acme' })
152
+ expect(listed.items.map((item) => item.principalId).sort()).toEqual(
153
+ [owner.id, member.id].sort(),
154
+ )
155
+ })
156
+
157
+ test('a member lists its orgs; a global grant lists none and says all', async () => {
158
+ expect(await memberApi.members.orgs()).toEqual({
159
+ all: false,
160
+ orgs: ['acme'],
161
+ })
162
+ const auditor = createClient(contract, 'api', {
163
+ fetch: kit.browser(kit.testCaller([{ action: 'tasks.read' }])),
164
+ })
165
+ expect(await auditor.members.orgs()).toEqual({ all: true, orgs: [] })
166
+ })
167
+
168
+ test('a caller of another org reaches nothing', async () => {
169
+ const project = await ownerApi.projects.create({
170
+ orgId: 'acme',
171
+ name: 'Secret',
172
+ })
173
+ const outsider = createClient(contract, 'api', {
174
+ fetch: kit.browser(
175
+ kit.testCaller([{
176
+ role: 'owner',
177
+ scope: { type: 'org', value: 'other' },
178
+ }]),
179
+ ),
180
+ })
181
+ expect(
182
+ await statusOf(
183
+ outsider.tasks.create({ projectId: project.id, title: 'Not mine' }),
184
+ ),
185
+ ).toBe(403)
186
+ expect((await outsider.tasks.mine({})).items).toEqual([])
187
+ })
188
+ })
189
+ }
190
+ ```
191
+
192
+ - `testDatabase` migrates a throwaway schema; without `FABRIKA_TEST_POSTGRES_URL` it answers `null`, prints
193
+ `<label> skipped: set FABRIKA_TEST_POSTGRES_URL to run the Postgres-backed tests`, and the file registers no test.
194
+ A skip proves nothing. To confirm a file ran, run it alone (`bun test --isolate src/tasks`) and check that the pass
195
+ count is its number of tests, or set `FABRIKA_TEST_POSTGRES_REQUIRED=1`: a missing URL then fails the file.
196
+ - `statusOf(call)` answers 200 when the call resolves and the status of the application's refusal otherwise; any other
197
+ failure (no answer, a bug in the test) is rethrown. Import it beside `testKit`; do not write your own.
198
+ - `kit.testCaller(grants, { kind })` makes a caller: a grant is a role or an action, in one scope or global. A
199
+ membership role is granted in one value of its scope; an action without `scope` is a global grant, for which
200
+ `ctx.within.values` is `'all'` (`kit.testCaller([{ action: 'tasks.read' }])`). `kind: 'machine'` presents a `px_`
201
+ key (`createClient(contract, 'api', { fetch: kit.fetch, key: caller.key })`).
202
+ - Build a scope you reuse with `kit.scope('org', 'acme')` (`acme` above). An object literal stored in a variable
203
+ widens its `type` to `string`, which tsc refuses as a grant's `scope`; a dimension the contract lacks is a type error.
204
+ - A caller's `id` is `<kind>-<n>` and its `label` `<kind>-<n>@<app>.test`, `n` counting the kit's callers; pass
205
+ `{ id, label }` to fix either (`kit.testCaller(grants, { label: 'Ann' })`). The label is a `string`, only a link
206
+ holder's (`kind: 'link'`) is `null`; it is `ctx.principal.label`, and the directory's label where it shows labels.
207
+ Assert a stored label against `caller.label` or the directory's answer (`members.list` above), never against a
208
+ literal of the default form.
209
+ - A human's `email` is `ctx.principal.email`: `<kind>-<n>@<app>.test` unless you pass `{ email }`, which the kit
210
+ lower-cases as IAM does (`kit.testCaller(grants, { email: 'Ann@Example.com' })` mails `ann@example.com`). A
211
+ `machine`, `app` or `link` caller's `email` is `null`. The SPA's `<prefix>/__fabrika-me.json` answers the caller's
212
+ label, email and actions in the kit as in production (`kit.browser(caller)('/__fabrika-me.json')`).
213
+ - `kit.browser(caller)` and `kit.fetch` serve one origin, `https://<app>.test` (`kit.origin`). Call a route by its
214
+ path (`kit.browser(owner)('/api/tasks.get', { method: 'POST', … })`), never with another host.
215
+ - An SPA mount is served from its built `dir` (`dist/web`) when the build exists, as the deploy serves it:
216
+ `kit.browser(owner)('/')` answers the built `index.html`, a file by its path, and every other path of the mount the
217
+ `index.html` again. Without a build it is an empty page. The directory is relative to the working directory, the
218
+ application root under `bun test`; pass `testKit(app, { root })` from elsewhere.
219
+ - The kit stands in for IAM's membership operations. A member directory (`ctx.members.list()` and `get(id)`) answers
220
+ from the kit's own callers: everyone `testCaller` gave a membership role in that scope, labels only where the
221
+ dimension declares `directory: 'labels'` (or to a `members.manage` holder). `kit.testCaller(grants, { lastSignInAt })`
222
+ is a person who signed in then; a `members.manage` holder's `list(ref)` answers it, and `null` for everyone else. The
223
+ application's own directory (`ctx.members.list()` of an `app: true` endpoint) lists every caller holding a role of the
224
+ contract, by id. To list someone, make them a caller:
225
+ `kit.testCaller([{ role: 'member', scope: { type: 'org', value: 'acme' } }])`. A `creates` endpoint, member
226
+ management and share links answer under IAM's rules for the caller's grants, and a role they give or remove is live
227
+ from the caller's next request, as in IAM: the creator of a value holds its creator role, a removed member is
228
+ refused and no longer listed.
229
+ - `kit.acceptInvitation(invitationId)` is the invited person accepting an invitation your handler opened
230
+ (`ctx.members.assign(ctx.target, { email }, role)`): it answers a new caller labelled by the address, holding the
231
+ role in that scope (an `external` person for an external role). Pass a caller to make an existing person the
232
+ member: `kit.acceptInvitation(invitationId, member)`. It throws as IAM refuses: an invitation that is not open, an
233
+ external person and a role not declared external (`not_eligible`), an inviter that has since lost the authority to
234
+ give the role (`not_allowed`).
235
+
236
+ ```ts src/members/__tests__/members.test.ts
237
+ import { createClient } from '@fabrika/app/client'
238
+ import { statusOf, testKit } from '@fabrika/app/testing'
239
+ import { testDatabase } from '@fabrika/db/testing'
240
+ import { afterAll, expect, test } from 'bun:test'
241
+ import { contract } from '../../../fabrika.contract'
242
+ import app from '../../app'
243
+
244
+ const postgres = await testDatabase({
245
+ migrations: new URL('../../../db/migrations/', import.meta.url),
246
+ label: 'members.test.ts',
247
+ })
248
+
249
+ if (postgres !== null) {
250
+ afterAll(() => postgres.close())
251
+ const kit = testKit(app, { database: postgres.database })
252
+ const owner = kit.testCaller([{
253
+ role: 'owner',
254
+ scope: kit.scope('org', 'acme'),
255
+ }])
256
+ const ownerApi = createClient(contract, 'api', { fetch: kit.browser(owner) })
257
+
258
+ test('an invited person accepts and is listed; once removed, it is refused', async () => {
259
+ const { invitationId } = await ownerApi.members.invite({
260
+ orgId: 'acme',
261
+ email: 'ann@example.test',
262
+ role: 'member',
263
+ })
264
+ const ann = kit.acceptInvitation(invitationId)
265
+ const annApi = createClient(contract, 'api', { fetch: kit.browser(ann) })
266
+ expect(
267
+ (await annApi.members.list({ orgId: 'acme' })).items.map((item) =>
268
+ item.principalId
269
+ ),
270
+ )
271
+ .toContain(ann.id)
272
+ await ownerApi.members.remove({ orgId: 'acme', principalId: ann.id })
273
+ expect(await statusOf(annApi.members.list({ orgId: 'acme' }))).toBe(403)
274
+ })
275
+ }
276
+ ```
277
+
278
+ - `testKit(app, { membership })` replaces the stand-in with your own `IamMembershipRpc`; that kit accepts no
279
+ invitation. `@fabrika/app/testing` exports the types of the options: `IamMembershipRpc` (`membership`),
280
+ `ChallengeFetch` (`challengeFetch`) and `FileScanner` (`scanner`). Import them from there, beside `testKit`.
281
+ - `kit.testCaller(grants, { disabled: true })` is a person an operator disabled: the directory lists and finds them
282
+ with `disabled: true`, so an assignment of them answers your handler's 400, and their own requests are refused (401 for
283
+ a session, 403 for a key).
284
+ - `kit.runJob('<job>')` runs a cron job once and answers `{ status: 'succeeded', attempts }`.
285
+ - An application with files passes `files: new MemoryFileObjects()` (`@fabrika/platform/testing`) to `testKit`;
286
+ without it the application does not boot. With `scan: true`, an upload stays `quarantined` until
287
+ `kit.runJob(FILE_SCAN_JOB)` (`@fabrika/app/implement`) runs with a `scanner` passed to `testKit`: a `FileScanner`
288
+ stand-in, or the real ClamAV one, `clamscanScanner()` of `@fabrika/app/bun`.
289
+ - A form's challenge token is single use: a second submission with the same token answers 400 `challenge_failed`,
290
+ also when the test's `challengeFetch` answers success. Send a fresh token with each submission
291
+ (`crypto.randomUUID()`).
292
+ - `testKit` takes no `deps`, and a test may not start a server (`Bun.serve` is `forbidden-global`). Stand in for an
293
+ outbound dependency through its variables: `env` points it at a test URL, and `spyOn(globalThis, 'fetch')` answers
294
+ that URL only and passes every other request on (`src/__tests__/mail-stand-in.ts` in `references/public-intake.md`).
295
+ - `defineDeps(build)` runs at every boot, the generated tests' too, which set no application variable: read a variable
296
+ where it is used, not in `build` (`references/public-intake.md`). A handler that calls an outbound dependency fails its
297
+ generated cases with 500, so an endpoint they cover writes an outbox row a job sends (`references/endpoint.md`).
298
+ - Run with `bun test --isolate`: the generated test boots the same application, and its data layer binds once per
299
+ process. Import the test kits from test files only (`test-only-import`).
300
+ - No `.skip`, `.only` or test without an assertion, and no `mock.module` of `bun:test` or `@fabrika/*`.
301
+
302
+ ## Clicking through the screens
303
+
304
+ `bunx fabrika app dev` serves the application through the same kit, seeded by `dev/seed.ts`, with a caller picker at
305
+ `/__fabrika/dev` and a toolbar on every SPA page: `references/dev.md`.
@@ -0,0 +1,190 @@
1
+ # references/webhooks-links-connectors.md — webhooks, share links and connectors
2
+
3
+ Each of these lets someone in who is not a signed-in member of the scope. Ask the human before you add one, and say
4
+ what it lets that caller do.
5
+
6
+ ## Webhooks (GitHub)
7
+
8
+ ```ts src/github/endpoints.ts
9
+ import { z } from 'zod/v4'
10
+ import { app } from '../app-kit'
11
+
12
+ const RepositoryEvent = z.strictObject({
13
+ action: z.string(),
14
+ repository: z.strictObject({ full_name: z.string().max(200) }),
15
+ })
16
+
17
+ /** A repository created in the bound GitHub account becomes a project of the org the binding is for. */
18
+ export const github = app.webhook('/hooks/github', {
19
+ scheme: 'github-sha256',
20
+ boundTo: 'org',
21
+ actions: ['tasks.write'],
22
+ input: RepositoryEvent,
23
+ })
24
+ .handle(async ({ input, ctx }) => {
25
+ if (input.action !== 'created') return
26
+ await ctx.db.in(ctx.refs['tasks.write']).projects.insert({
27
+ name: input.repository.full_name,
28
+ })
29
+ })
30
+ ```
31
+
32
+ - Mount it by name in the contract: `github,` beside `api`. It answers `POST /hooks/github/:bindingId`.
33
+ - `boundTo` is a dimension with `members`. A holder of its `members.manage` binds an org to a GitHub account with the
34
+ generated `POST /api/webhooks.github.bind` (`{ scope, account: 'installation:<id>' | 'repository:<id>' }`), which
35
+ answers the secret and the URL once. The generated tests need a fixture for it (`references/tests.md`).
36
+ - The framework checks the signature, the account and the input (undeclared fields are dropped).
37
+ `ctx.refs['<action>']` is a `Ref` of the bound org for each declared action.
38
+ - It records the delivery key (a digest of the binding and the body) before the handler runs, and answers a body it
39
+ already ran in the last 30 days without running it again. A handler that throws commits nothing, and the key is
40
+ forgotten, so GitHub's redelivery runs it again from nothing. A process that dies after the record and before the
41
+ commit keeps the key and loses the delivery. So make every effect idempotent, and key an external effect after the
42
+ data work on `ctx.delivery.key`; never rely on exactly one run.
43
+
44
+ ## Share links
45
+
46
+ ```ts src/sharing/endpoints.ts
47
+ import { z } from 'zod/v4'
48
+ import { app } from '../app-kit'
49
+ import { TaskStatus } from '../schemas'
50
+
51
+ const ShareTask = z.strictObject({ id: z.uuid(), days: z.int().min(1).max(30) })
52
+ const Shared = z.strictObject({ url: z.string() })
53
+ const SharedTask = z.strictObject({ title: z.string(), status: TaskStatus })
54
+
55
+ export const sharing = app.group({ callers: ['human'] }, (r) => ({
56
+ share: r.mutation({
57
+ input: ShareTask,
58
+ output: Shared,
59
+ access: { action: 'tasks.write', on: 'task', from: 'id' },
60
+ issues: 'shared',
61
+ })
62
+ .handle(async ({ input, ctx }) => ({
63
+ url:
64
+ (await ctx.links.issue(ctx.target, { mount: 'shared', days: input.days }))
65
+ .url,
66
+ })),
67
+ }))
68
+
69
+ export const shared = app.link('/s', {
70
+ spa: 'dist/share',
71
+ dimension: 'task',
72
+ grants: ['tasks.read'],
73
+ issuedBy: 'tasks.write',
74
+ maxDays: 30,
75
+ procedures: (l) => ({
76
+ task: l.query({ output: SharedTask, access: { action: 'tasks.read' } })
77
+ .handle(async ({ ctx }) => {
78
+ const task = await ctx.db.target.get()
79
+ return { title: task.title, status: task.status }
80
+ }),
81
+ }),
82
+ })
83
+ ```
84
+
85
+ - Mount both: `api: c.rpc('/api', { …, sharing })` and `shared,`. The link's procedures are `POST /s/api/<name>` and
86
+ its SPA is served under `/s/`.
87
+ - The issuing endpoint declares `issues: '<mount>'` and its action is the mount's `issuedBy`. IAM binds the key to the
88
+ task's whole chain; the answer is `<prefix>/#<key>`, and a moved task's key reaches nothing.
89
+ - The share SPA reads the key with `shareKey()` of `@fabrika/app/client` and calls
90
+ `createClient(contract, 'shared', { key })`. Never read `location.hash`.
91
+ - Issue the link after the data work: IAM does not roll back with the transaction.
92
+ - `ctx.links.issue` answers `{ id, url, expiresAt }`. A holder of that link calls with `ctx.principal.kind === 'link'`
93
+ and `ctx.principal.credentialId` equal to that `id`, so a row that stores the `id` finds what the link was issued
94
+ for. Keep the `id` (never the key) to end the link later with `ctx.links.revoke(id)`, which the same `issues` declaration gives; its issuer or a holder of `members.manage` on its
95
+ chain may. A revoked link still works until the proxy's token for it expires, at most 5 minutes.
96
+ - A link procedure acts on the link's own object (`ctx.db.target`), with no `from` and no id in its input. A link
97
+ that should work once records its use in the row: a `verify` sets `verified_at` once under `getForUpdate`, and a
98
+ second call answers the same. IAM counts no uses.
99
+ - A cron job may issue links too (`issues` on `app.cron`, `references/jobs.md`): a visitor without an account gets a
100
+ link by e-mail from a job that drains a form's inbox. `references/public-intake.md` is that recipe end to end, with
101
+ the files a link holder uploads (`link: true`, or `link: ['<mount>']` for some mounts only, `references/files.md`).
102
+
103
+ ## Connectors: another application's data
104
+
105
+ A consumer imports an owner's export. The snapshot comes from Control, so this step needs an operator's credential,
106
+ not the application's: `FABRIKA_CONTROL_URL` (or `--url=<control origin>`) and `FABRIKA_CONTROL_KEY`, a machine key
107
+ that holds `deploy.read` on the consumer, both in the shell that runs the command only. Ask the human for them; they
108
+ are CLI credentials, never application variables, and never committed.
109
+
110
+ ```bash
111
+ bunx fabrika connect add catalog/products@1 --consumer=tasks --env=prod # writes fabrika/connect/catalog.products.v1.ts
112
+ bunx fabrika connect pull catalog/products@1 --consumer=tasks --env=prod # refreshes it from what the owner approved now
113
+ ```
114
+
115
+ The kit takes the snapshot, and an endpoint that calls it declares `connect`:
116
+
117
+ ```ts src/app-kit.ts
118
+ import { defineApp } from '@fabrika/app/contract'
119
+ import { catalogProductsV1 } from '../fabrika/connect/catalog.products.v1'
120
+ import { tables } from './db/tables'
121
+ import { vocabulary } from './vocabulary'
122
+
123
+ export const app = defineApp(vocabulary, tables, {
124
+ imports: { catalog: { snapshot: catalogProductsV1, procedures: ['get'] } },
125
+ })
126
+ ```
127
+
128
+ ```ts src/products/endpoints.ts
129
+ import { z } from 'zod/v4'
130
+ import { app } from '../app-kit'
131
+
132
+ const BySku = z.strictObject({
133
+ orgId: z.string().min(1).max(100),
134
+ sku: z.string().min(1).max(64),
135
+ })
136
+ const ProductName = z.strictObject({
137
+ name: z.nullable(z.string()),
138
+ status: z.enum(['found', 'missing', 'unavailable']),
139
+ })
140
+
141
+ export const products = app.group({ callers: ['human'] }, (r) => ({
142
+ name: r.query({
143
+ input: BySku,
144
+ output: ProductName,
145
+ access: { action: 'tasks.read', in: 'org', from: 'orgId' },
146
+ connect: ['catalog'],
147
+ })
148
+ .handle(async ({ input, ctx }) => {
149
+ const product = await ctx.connect.catalog.get({ sku: input.sku })
150
+ if (product.outcome !== 'ok') return { name: null, status: 'unavailable' }
151
+ return product.value === null
152
+ ? { name: null, status: 'missing' }
153
+ : { name: product.value.name, status: 'found' }
154
+ }),
155
+ }))
156
+ ```
157
+
158
+ - A call never throws for the owner's answer and never becomes an HTTP error by itself: it resolves to an outcome,
159
+ `ok`, `denied`, `invalid_input`, `unavailable`, `timeout` or `contract_violation`. Decide what each one answers, as
160
+ the example does: a missing product and an unreachable owner are different answers.
161
+ - Exports are read-only and carry no per-user data. When the export you need does not exist, ask the owner
162
+ application's maintainer. The platform writes `FABRIKA_CONNECT_*`; never read or set them.
163
+
164
+ The owner side is an export, mounted under `exports` in the contract. Its procedures run read-only for the consumer
165
+ applications it names:
166
+
167
+ ```ts src/billing/endpoints.ts
168
+ import { z } from 'zod/v4'
169
+ import { app } from '../app-kit'
170
+
171
+ const ProjectCount = z.strictObject({ projects: z.int() })
172
+
173
+ export const projectCounts = app.export({
174
+ version: 1,
175
+ consumers: ['billing'],
176
+ audience: 'any consumer user',
177
+ procedures: (e) => ({
178
+ count: e.query({
179
+ output: ProjectCount,
180
+ access: { action: 'tasks.read', over: 'org' },
181
+ })
182
+ .handle(async ({ ctx }) => ({
183
+ projects: await ctx.db.within.projects.count(),
184
+ })),
185
+ }),
186
+ })
187
+ ```
188
+
189
+ Change a published export only by adding to it (a new procedure, a new optional input field) or by a new version
190
+ served beside the old one.
@@ -0,0 +1,77 @@
1
+ // @fabrika/app/agent-skill — the one source of the rules every agent that writes an application reads: the builder
2
+ // installs `skill/` as a skill, Control's MCP server answers it from `get_app_rules`, and the template's AGENTS.md is
3
+ // `renderAgentsFile`. Reads files of a package, so only Bun or Node code imports it.
4
+
5
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
6
+ import { dirname, join } from 'node:path'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ export const APP_AGENT_SKILL_NAME = 'fabrika-app'
10
+
11
+ const SKILL_DIR = fileURLToPath(new URL('../skill', import.meta.url))
12
+ const SKILL_FILE = 'SKILL.md'
13
+ const REFERENCES_DIR = 'references'
14
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---\n+/
15
+ const VERSION_LINE = /^version: (\S+)$/m
16
+
17
+ export interface AppAgentSkillReference {
18
+ /** The file's path in the skill directory, `references/<topic>.md`: the path `SKILL.md` names it by. */
19
+ readonly path: string
20
+ /** The topic `get_app_rules` answers it under: its file name without `.md`. */
21
+ readonly topic: string
22
+ readonly text: string
23
+ }
24
+
25
+ export interface AppAgentSkill {
26
+ /** The `version` of the frontmatter: the version of the `@fabrika/app` that ships it. */
27
+ readonly version: string
28
+ /** The whole `SKILL.md`, frontmatter included: what a skill directory holds. */
29
+ readonly text: string
30
+ /** `SKILL.md` without the frontmatter. */
31
+ readonly body: string
32
+ /** The reference files, by path. */
33
+ readonly references: readonly AppAgentSkillReference[]
34
+ }
35
+
36
+ /** The skill of this package. */
37
+ export function readAppAgentSkill(): AppAgentSkill {
38
+ return readAppAgentSkillAt(SKILL_DIR)
39
+ }
40
+
41
+ /** The skill in `skillDir`, the `skill/` directory of an installed `@fabrika/app`. */
42
+ export function readAppAgentSkillAt(skillDir: string): AppAgentSkill {
43
+ const text = readFileSync(join(skillDir, SKILL_FILE), 'utf-8')
44
+ const frontmatter = FRONTMATTER.exec(text)
45
+ if (frontmatter === null) throw new Error('@fabrika/app: skill/SKILL.md has no frontmatter')
46
+ const version = VERSION_LINE.exec(frontmatter[1] ?? '')?.[1]
47
+ if (version === undefined) throw new Error('@fabrika/app: skill/SKILL.md has no version in its frontmatter')
48
+ const referencesDir = join(skillDir, REFERENCES_DIR)
49
+ const names = existsSync(referencesDir) ? readdirSync(referencesDir).filter((name) => name.endsWith('.md')).sort() : []
50
+ const references = names.map((name) => ({
51
+ path: `${REFERENCES_DIR}/${name}`,
52
+ topic: name.slice(0, -'.md'.length),
53
+ text: readFileSync(join(referencesDir, name), 'utf-8'),
54
+ }))
55
+ return { version, text, body: text.slice(frontmatter[0].length), references }
56
+ }
57
+
58
+ /**
59
+ * The skill of the `@fabrika/app` an application installed: the nearest `node_modules/@fabrika/app` from `appRoot`
60
+ * up, as Bun resolves the package. `null` when the application has not installed it.
61
+ */
62
+ export function installedAppAgentSkill(appRoot: string): AppAgentSkill | null {
63
+ let directory = appRoot
64
+ for (;;) {
65
+ const skillDir = join(directory, 'node_modules', '@fabrika', 'app', 'skill')
66
+ if (existsSync(join(skillDir, SKILL_FILE))) return readAppAgentSkillAt(skillDir)
67
+ const parent = dirname(directory)
68
+ if (parent === directory) return null
69
+ directory = parent
70
+ }
71
+ }
72
+
73
+ /** The `AGENTS.md` of an application repository: the skill's body, then each reference file. */
74
+ export function renderAgentsFile(skill: AppAgentSkill): string {
75
+ const marker = `<!-- Generated from skill/ of @fabrika/app ${skill.version}. Change those files, not this one. -->\n\n`
76
+ return marker + [skill.body, ...skill.references.map((reference) => reference.text)].join('\n---\n\n')
77
+ }
package/src/build.ts ADDED
@@ -0,0 +1,53 @@
1
+ // @fabrika/app/build — the generated entrypoint of a contract application, which `fabrika app build` writes. It is
2
+ // the only module that reads `process.env` and the only caller of `boot`, through the runtime adapter, and it imports
3
+ // the application only after the adapter took the framework's variables out of the process.
4
+
5
+ import { mkdir, writeFile } from 'node:fs/promises'
6
+ import { dirname, join } from 'node:path'
7
+
8
+ /** An application root with this file is a contract application. The paths equal `@fabrika/check`'s layout. */
9
+ export const CONTRACT_FILE = 'fabrika.contract.ts'
10
+
11
+ /** Relative to the application root. */
12
+ export const GENERATED_ENTRY_FILE = 'fabrika/entry.generated.ts'
13
+
14
+ /** The module whose default export is `serve(contract, …)`, relative to the application root. */
15
+ export const IMPLEMENTATION_FILE = 'src/app.ts'
16
+
17
+ export type EntryRuntime = 'bun' | 'cloudflare'
18
+
19
+ const HEADER = '// @generated by `fabrika app build` from src/app.ts. Do not edit: every build rewrites it.'
20
+
21
+ /** The entrypoint source for one runtime. */
22
+ export function generatedEntry(runtime: EntryRuntime): string {
23
+ switch (runtime) {
24
+ case 'bun':
25
+ return [
26
+ HEADER,
27
+ "import { markProductionProcess, serveContract } from '@fabrika/app/bun'",
28
+ '',
29
+ '// The test kits refuse to load from here on; the application is imported only after the framework took its variables.',
30
+ 'markProductionProcess()',
31
+ "await serveContract(() => import('../src/app'), process.env)",
32
+ '',
33
+ ].join('\n')
34
+ case 'cloudflare':
35
+ return [
36
+ HEADER,
37
+ "import { createContractWorker, markProductionProcess } from '@fabrika/app/cloudflare'",
38
+ '',
39
+ '// The test kits refuse to load from here on; the application is imported only after the framework took its variables.',
40
+ 'markProductionProcess()',
41
+ "export default createContractWorker(() => import('../src/app'))",
42
+ '',
43
+ ].join('\n')
44
+ }
45
+ }
46
+
47
+ /** Writes the entrypoint under `root` and returns its path. */
48
+ export async function writeGeneratedEntry(root: string, runtime: EntryRuntime): Promise<string> {
49
+ const path = join(root, GENERATED_ENTRY_FILE)
50
+ await mkdir(dirname(path), { recursive: true })
51
+ await writeFile(path, generatedEntry(runtime))
52
+ return path
53
+ }