@fabrika/app 0.3.3 → 0.4.1

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 (116) hide show
  1. package/README.md +78 -90
  2. package/package.json +77 -6
  3. package/skill/SKILL.md +319 -0
  4. package/skill/references/app-setup.md +370 -0
  5. package/skill/references/dev.md +90 -0
  6. package/skill/references/endpoint.md +640 -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 +746 -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 +308 -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 +331 -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 +1932 -0
  30. package/src/contract/compile.ts +143 -0
  31. package/src/contract/connect.ts +170 -0
  32. package/src/contract/context.ts +444 -0
  33. package/src/contract/declaration.ts +310 -0
  34. package/src/contract/defaults.ts +161 -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 +824 -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 +120 -0
  64. package/src/implement/connect.ts +386 -0
  65. package/src/implement/dispatch.ts +1095 -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 +760 -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 +281 -0
  90. package/src/react/index.ts +34 -0
  91. package/src/react/provider.tsx +179 -0
  92. package/src/react/store.ts +215 -0
  93. package/src/react/testing.ts +144 -0
  94. package/src/react/types.ts +40 -0
  95. package/src/rpc/dispatcher.ts +4 -19
  96. package/src/service/define.ts +121 -0
  97. package/src/service/index.ts +21 -0
  98. package/src/service/serve.ts +99 -0
  99. package/src/testing/caller.ts +123 -0
  100. package/src/testing/challenge.ts +10 -0
  101. package/src/testing/dev.ts +99 -0
  102. package/src/testing/endpoint-access.ts +809 -0
  103. package/src/testing/index.ts +36 -0
  104. package/src/testing/isolation.ts +25 -0
  105. package/src/testing/kit.ts +585 -0
  106. package/src/testing/membership.ts +797 -0
  107. package/src/testing/policy-isolation.ts +827 -0
  108. package/src/testing/preload.ts +6 -0
  109. package/src/testing/proxy.ts +87 -0
  110. package/src/testing/runner.ts +61 -0
  111. package/src/testing/status.ts +19 -0
  112. package/src/undumpable.ts +42 -0
  113. package/src/app.ts +0 -76
  114. package/src/middleware.ts +0 -38
  115. package/src/router.ts +0 -149
  116. package/src/testing.ts +0 -119
@@ -0,0 +1,370 @@
1
+ # references/app-setup.md — the repository of an application
2
+
3
+ An application is the root of its own repository: Zerops reads `zerops.yaml` there. In the fabrika-platform
4
+ repository an example is a workspace under `examples/<name>/`, laid out the same way. This file shows an application
5
+ with tables and an SPA; a new one starts without either (`references/start.md`), and each part below that belongs to
6
+ the SPA is added with it.
7
+
8
+ ## Packages and compiler
9
+
10
+ ```json package.json
11
+ {
12
+ "name": "tasks",
13
+ "private": true,
14
+ "type": "module",
15
+ "scripts": {
16
+ "build:manifest": "fabrika app build --env=prod",
17
+ "build:web": "bun build-web.ts",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "bun test --isolate"
20
+ },
21
+ "dependencies": {
22
+ "@fabrika/app": "^0.4.1",
23
+ "@fabrika/db": "^0.4.1",
24
+ "@fabrika/provider-zerops": "^0.4.1",
25
+ "@fabrika/proxy-core": "^0.4.1",
26
+ "@fabrika/ui": "^0.4.1",
27
+ "@sentry/browser": "10.69.0",
28
+ "kysely": "0.29.6",
29
+ "react": "^19.0.0",
30
+ "react-dom": "^19.0.0",
31
+ "zod": "^3.25.76"
32
+ },
33
+ "devDependencies": {
34
+ "@fabrika/check": "^0.4.1",
35
+ "@fabrika/cli": "^0.4.1",
36
+ "@fabrika/platform": "^0.4.1",
37
+ "@happy-dom/global-registrator": "^20.11.2",
38
+ "@types/bun": "latest",
39
+ "@types/react": "^19.0.0",
40
+ "@types/react-dom": "^19.0.0",
41
+ "typescript": "^5.9.3"
42
+ }
43
+ }
44
+ ```
45
+
46
+ - The name is not under `@fabrika/*` (that scope is the platform's), and every `@fabrika/*` version is a registry
47
+ range, never `workspace:*`. The checker refuses both (`package-replacement`). Inside the fabrika-platform repository
48
+ Bun still links the workspace copies.
49
+ - `build:web` builds the SPA: add it, `build-web.ts`, `web/`, the contract's `site` mount and the `bun run build:web`
50
+ line of `zerops.yaml` when the application gets an SPA, and not before.
51
+ - `kysely` is there for the generated schema's types. `@sentry/browser` reports the SPA's errors to Operations, from
52
+ the DSN and release the framework serves at `<prefix>/__fabrika-config.json`. 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
55
+ target) there, never as a build-time constant; it holds no other variable, and the contract refuses a secret in it.
56
+ `react`, `react-dom` and `@fabrika/ui` (with `@types/react`, `@types/react-dom` and `@happy-dom/global-registrator`
57
+ for the screen tests) are the SPA's: add them with it.
58
+ - After you create a new workspace in a monorepo, run `bun install` at the repository root. Until then every
59
+ `@fabrika/*` import fails and tsc reports a cascade of implicit `any`s that are not your bugs.
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.
66
+ - The local pack is the one way to run a platform checkout's packages in an application. Never `bun link` a
67
+ `@fabrika/*` package: a linked package resolves its dependencies and peers from the checkout, so the application
68
+ runs a second React (every hook fails), a second zod and a second kysely. Installed from `vendor/`, the packages
69
+ 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`.
72
+
73
+ ```json tsconfig.json
74
+ {
75
+ "compilerOptions": {
76
+ "target": "ES2022",
77
+ "module": "ESNext",
78
+ "moduleResolution": "bundler",
79
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
80
+ "jsx": "react-jsx",
81
+ "strict": true,
82
+ "noUncheckedIndexedAccess": true,
83
+ "noImplicitOverride": true,
84
+ "noPropertyAccessFromIndexSignature": true,
85
+ "forceConsistentCasingInFileNames": true,
86
+ "skipLibCheck": true,
87
+ "isolatedModules": true,
88
+ "noEmit": true,
89
+ "types": ["@types/bun"]
90
+ },
91
+ "include": ["src/**/*", "web/**/*", "dev/**/*", "*.ts"]
92
+ }
93
+ ```
94
+
95
+ The tsconfig stands alone: no `extends` of a file outside the application. `lib` is ES2022, so `toSorted` and other
96
+ newer methods are not there.
97
+
98
+ ## The composition
99
+
100
+ ```ts src/app-kit.ts
101
+ import { defineApp } from '@fabrika/app/contract'
102
+ import { tables } from './db/tables'
103
+ import { vocabulary } from './vocabulary'
104
+
105
+ export const app = defineApp(vocabulary, tables)
106
+ ```
107
+
108
+ ```ts fabrika.contract.ts
109
+ import { app } from './src/app-kit'
110
+ import { comments } from './src/comments/endpoints'
111
+ import { members } from './src/members/endpoints'
112
+ import { priorities } from './src/priorities/endpoints'
113
+ import { projects } from './src/projects/endpoints'
114
+ import { reports } from './src/reports/endpoints'
115
+ import { tasks } from './src/tasks/endpoints'
116
+
117
+ export const contract = app.contract((c) => ({
118
+ api: c.rpc('/api', {
119
+ projects,
120
+ tasks,
121
+ comments,
122
+ reports,
123
+ members,
124
+ priorities,
125
+ }),
126
+ site: c.spa('/', { dir: 'dist/web', access: { member: true } }),
127
+ health: c.health('/healthz'),
128
+ }))
129
+ ```
130
+
131
+ ```ts src/app.ts
132
+ import { serve } from '@fabrika/app/implement'
133
+ import { scoped } from '@fabrika/db'
134
+ import { contract } from '../fabrika.contract'
135
+ import { tables } from './db/tables'
136
+
137
+ export default serve(contract, { database: scoped(tables) })
138
+ ```
139
+
140
+ - 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
143
+ anyone opens without signing in (a landing page, a public form): the approver sees it as "for anyone". A public SPA
144
+ at `/` answers every `GET` no more specific endpoint serves; give a member SPA its own prefix (`/app`). Sections:
145
+ `files`, `jobs`, `exports`, `csp` (`connectSrc`, `imgSrc`, `frameAncestors`, `scriptSrc`, `frameSrc`, `styleSrc`,
146
+ `fontSrc`: exact `https://` origins, each with a `purpose`, at most 16 each). A hosted typeface needs both its
147
+ 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. 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.
165
+ - `src/app.ts` composes and does nothing else. A dependency built from the application's own variables is
166
+ `defineDeps(build)`, passed as `serve(contract, { database, deps })` and read in a handler with `get()`. Never read
167
+ `process.env`. `build` runs at every boot, the generated tests' too, which pass no variable: read a variable when
168
+ the dependency is used, and fail there (`src/mail.ts` in `references/public-intake.md`). A handler of an endpoint the
169
+ generated tests cover records an outbox row instead, and a job calls the dependency (`references/endpoint.md`).
170
+
171
+ ## The deploy surface (Zerops)
172
+
173
+ ```ts fabrika.config.ts
174
+ import { compileContract, schemaFromContract } from '@fabrika/app/contract'
175
+ import { defineApp, type ZeropsServiceSpec } from '@fabrika/provider-zerops'
176
+ import { contractProxyTable } from '@fabrika/proxy-core/manifest'
177
+ import { contract } from './fabrika.contract'
178
+ import { tables } from './src/db/tables'
179
+
180
+ const compiled = compileContract(contract, tables)
181
+
182
+ const services = (): ZeropsServiceSpec[] => [
183
+ {
184
+ hostname: 'tasksdb',
185
+ type: 'postgresql:single@18',
186
+ profile: 'oltp-hobby',
187
+ priority: 100,
188
+ },
189
+ {
190
+ hostname: 'tasksapi',
191
+ type: 'alpine/bun@1.3',
192
+ priority: 10,
193
+ enableSubdomainAccess: false,
194
+ minContainers: 1,
195
+ maxContainers: 2,
196
+ },
197
+ ]
198
+
199
+ export default defineApp({
200
+ id: contract.vocabulary.app,
201
+ schema: schemaFromContract(compiled),
202
+ target: {
203
+ platform: 'zerops',
204
+ services,
205
+ contract: compiled,
206
+ proxy: { upstream: 'tasksapi:3000', ...contractProxyTable(compiled) },
207
+ deployService: 'tasksapi',
208
+ zeropsSetup: 'tasksapi',
209
+ },
210
+ })
211
+ ```
212
+
213
+ ```yaml
214
+ # zerops.yaml: write only build.base, build.os and the SPA's buildCommands; the platform generates the rest.
215
+ # Without an SPA, the build holds only base: the platform installs the packages itself.
216
+ zerops:
217
+ - setup: tasksapi
218
+ build:
219
+ base: alpine/bun@1.3
220
+ buildCommands:
221
+ - bun install --frozen-lockfile
222
+ - bun run build:web
223
+ ```
224
+
225
+ The platform's build runs the tests, writes the entrypoint, runs `fabrika-check` and the type check. Its start script
226
+ runs `fabrika db migrate`, `fabrika db codegen --verify` and `fabrika db test-isolation` before the server: the
227
+ application is not ready until every generated test passes. It writes every `FABRIKA_*` variable; never set one.
228
+
229
+ ## The SPA (added when the application has one)
230
+
231
+ The SPA is React, built from the screens and hooks of the framework: `@fabrika/app/react` runs the typed client's
232
+ calls, and `@fabrika/ui` holds the components. Build a screen from the kit; never write a DOM helper, a router or a
233
+ component set of your own. `references/spa.md` shows every component, the forms, the routes and the tests.
234
+
235
+ ```html web/index.html
236
+ <!doctype html>
237
+ <html lang="en">
238
+ <head>
239
+ <meta charset="utf-8">
240
+ <meta name="viewport" content="width=device-width, initial-scale=1">
241
+ <title>Tasks</title>
242
+ <script type="module" src="./start.tsx"></script>
243
+ </head>
244
+ <body>
245
+ <div id="root"></div>
246
+ </body>
247
+ </html>
248
+ ```
249
+
250
+ ```ts web/api.ts
251
+ import { createClient } from '@fabrika/app/client'
252
+ import { contract } from '../fabrika.contract'
253
+
254
+ export const api = createClient(contract, 'api', { bounceOnAuth: true })
255
+ ```
256
+
257
+ ```tsx web/mine.tsx
258
+ import { useInfiniteQuery } from '@fabrika/app/react'
259
+ import { type Column, DataTable, PageHeader, StatusPill } from '@fabrika/ui'
260
+ import { api } from './api'
261
+
262
+ type Task = {
263
+ readonly id: string
264
+ readonly title: string
265
+ readonly status: string
266
+ }
267
+
268
+ const columns: readonly Column<Task>[] = [
269
+ { key: 'title', header: 'Task', grow: true, cell: (task) => task.title },
270
+ {
271
+ key: 'status',
272
+ header: 'Status',
273
+ cell: (task) => <StatusPill>{task.status}</StatusPill>,
274
+ },
275
+ ]
276
+
277
+ export function MyTasks() {
278
+ const mine = useInfiniteQuery(api.tasks.mine, {})
279
+ return (
280
+ <>
281
+ <PageHeader title="My tasks" lead="Open tasks assigned to you." />
282
+ <DataTable
283
+ caption="My tasks"
284
+ columns={columns}
285
+ rows={mine.items}
286
+ rowKey={(task) => task.id}
287
+ status={mine.status === 'error'
288
+ ? 'error'
289
+ : mine.status === 'success'
290
+ ? 'ready'
291
+ : 'loading'}
292
+ error={mine.error?.message}
293
+ onRetry={() => void mine.retry()}
294
+ loadMore={{
295
+ hasMore: mine.hasMore,
296
+ loading: mine.loadingMore,
297
+ onLoadMore: () => void mine.loadMore(),
298
+ }}
299
+ />
300
+ </>
301
+ )
302
+ }
303
+ ```
304
+
305
+ ```tsx web/start.tsx
306
+ import '@fabrika/ui/styles.css'
307
+ import { FabrikaProvider } from '@fabrika/app/react'
308
+ import { AppShell } from '@fabrika/ui'
309
+ import { StrictMode } from 'react'
310
+ import { createRoot } from 'react-dom/client'
311
+ import { api } from './api'
312
+ import { MyTasks } from './mine'
313
+
314
+ const root = document.getElementById('root')
315
+ if (root !== null) {
316
+ createRoot(root).render(
317
+ <StrictMode>
318
+ <FabrikaProvider clients={[api]}>
319
+ <AppShell
320
+ brand="Tasks"
321
+ nav={[{ label: 'My tasks', href: '/', active: true }]}
322
+ >
323
+ <MyTasks />
324
+ </AppShell>
325
+ </FabrikaProvider>
326
+ </StrictMode>,
327
+ )
328
+ }
329
+ ```
330
+
331
+ ```ts build-web.ts
332
+ import { shellSlicePlugin } from '@fabrika/check/bun-plugin'
333
+ import { join } from 'node:path'
334
+
335
+ const outdir = join(import.meta.dir, 'dist', 'web')
336
+ const built = await Bun.build({
337
+ entrypoints: [join(import.meta.dir, 'web', 'index.html')],
338
+ outdir,
339
+ target: 'browser',
340
+ define: { 'process.env.NODE_ENV': JSON.stringify('production') },
341
+ plugins: [shellSlicePlugin({ root: import.meta.dir })],
342
+ })
343
+ if (!built.success) {
344
+ for (const log of built.logs) console.error(String(log))
345
+ process.exit(1)
346
+ }
347
+ const index = Bun.file(join(outdir, 'index.html'))
348
+ await Bun.write(
349
+ index,
350
+ (await index.text()).replaceAll(/(src|href)="\.\//g, '$1="/'),
351
+ )
352
+ ```
353
+
354
+ - The SPA imports `@fabrika/app/client`, `@fabrika/app/react`, `@fabrika/ui`, React, `fabrika.contract.ts`,
355
+ `src/schemas.ts`, `src/shared/` and its own modules; never a slice, `src/db/` or a Bun or Node module. The plugin loads the
356
+ contract without its handlers. A row type the screen declares (`Task` above) is checked against the answer where
357
+ the rows are passed in.
358
+ - The last step points the page's assets at the SPA's prefix (`/`): the mount answers `index.html` for every path
359
+ under it, so a deep link such as `/tasks/<id>` renders. `define` gives React's production build.
360
+ - Set text through JSX, never `dangerouslySetInnerHTML` or `innerHTML`. Same-origin calls carry the session cookie.
361
+ - **Who is signed in.** Every member SPA serves `<prefix>/__fabrika-me.json` to the person it admitted:
362
+ `{ label, email, actions: { app, scopes } }`. `useCaller()` (`@fabrika/app/react`) answers `{ label, email,
363
+ can(action, scope?) }` from it, and `<Can action="tasks.write">` shows its children only when the token grants the
364
+ action. It comes from the token and asks IAM nothing. Use it to greet the person and to show or hide a control,
365
+ never as the check: every endpoint decides again, so a hidden button is no protection. A public SPA answers it 401,
366
+ because its pages arrive without a token, signed in or not.
367
+
368
+ ## Ignored files
369
+
370
+ `.gitignore`: `node_modules/`, `fabrika.manifest.json`, `/fabrika/entry.generated.ts`, `/dist/`.
@@ -0,0 +1,90 @@
1
+ # references/dev.md — previewing the application with `fabrika app dev`
2
+
3
+ `bunx fabrika app dev` serves the application on `http://127.0.0.1:3000/` through its test kit: the proxy decision,
4
+ the stand-in IAM, a throwaway schema of `FABRIKA_TEST_POSTGRES_URL`, and each SPA from its build (run
5
+ `bun run build:web` first). Use it to look at the screens, never in place of a test.
6
+
7
+ ## The seed
8
+
9
+ `dev/seed.ts` runs once, before the first request. It receives the kit, the API the tests use, writes through the
10
+ endpoints, and answers whom and what the picker offers. `--seed=<module>` names another file.
11
+
12
+ ```ts dev/seed.ts
13
+ import { createClient } from '@fabrika/app/client'
14
+ import type { DevSeed } from '@fabrika/app/testing'
15
+ import { contract } from '../fabrika.contract'
16
+
17
+ const seed: DevSeed<typeof contract> = async (dev) => {
18
+ const acme = dev.scope('org', 'acme')
19
+ const ada = dev.testCaller([{ role: 'owner', scope: acme }], {
20
+ label: 'Ada Lovelace',
21
+ email: 'ada@acme.example',
22
+ })
23
+ const ben = dev.testCaller([{ role: 'member', scope: acme }], {
24
+ label: 'Ben Okafor',
25
+ email: 'ben@acme.example',
26
+ })
27
+ const api = createClient(contract, 'api', { fetch: dev.browser(ada) })
28
+ await api.projects.create({ orgId: acme.value, name: 'Website relaunch' })
29
+ await api.members.invite({
30
+ orgId: acme.value,
31
+ email: 'cleo@acme.example',
32
+ role: 'member',
33
+ })
34
+ return {
35
+ personas: [
36
+ { name: 'Ada', description: 'Owns Acme: invites and assigns', caller: ada },
37
+ { name: 'Ben', description: 'A member who works the tasks', caller: ben },
38
+ ],
39
+ scopes: { org: [{ value: acme.value, label: 'Acme' }] },
40
+ }
41
+ }
42
+
43
+ export default seed
44
+ ```
45
+
46
+ - A persona is a person the seed made with `dev.testCaller` (a session, never `via: 'key'`), with a unique `name` and
47
+ a one-line `description`. The picker shows its roles, the scope values it holds them in, and `external`.
48
+ - `links` lists pages a link holder opens: `{ title, description, url }`, where `url` is what the application's own
49
+ endpoint answered from `ctx.links.issue` (`/s/#<key>`). Never build a link URL by hand.
50
+ - `scopes` names the values the seed made, by dimension, with the label a person knows them by; the picker offers
51
+ them for a membership role.
52
+ - Check every call the seed makes: an endpoint that refuses answers a status, and the seed goes on without its data.
53
+ Throw when one does: a seed that throws stops `app dev` and prints its error with the stack.
54
+ - `dev.runJob('<job>')` runs a cron job from the seed (`app dev` runs none on its schedule). A job's `ctx.links` works:
55
+ the stand-in IAM takes the job's own key. What a job's handler throws is printed with its stack, here only.
56
+
57
+ ## Outside the application
58
+
59
+ - `dev/dev.env` holds the application's variables for `app dev`, one `NAME=value` per line (`#` comments, quotes
60
+ optional). They win over the shell's; a `FABRIKA_*` name is refused. Commit it: it holds stand-in values, never a
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
64
+ does: a token is single use. For the widget on a page, set the `siteKey` variable to Turnstile's always-passing test
65
+ key `1x00000000000000000000AA`. Its token is the same every time, and `app dev` admits that one token again at each
66
+ submission; a deployed application records it like any other, so there it passes once.
67
+ - Point an outside provider at the dev outbox: `app dev` captures every request the process sends to
68
+ `https://outbox.invalid` (`DEV_OUTBOX_ORIGIN` of `@fabrika/app/testing`) and answers `200 {"id": …}`. The mail view
69
+ lists each one, by the `to` and `subject` of a JSON body, and opens its body; its headers are not kept.
70
+ - The seed's second argument reads the outbox: `outside.outbox()` is every captured request, oldest first
71
+ (`{ id, method, path, contentType, body }`), so the seed can open a link the application mailed.
72
+
73
+ ```text dev/dev.env
74
+ # Stand-in values for fabrika app dev.
75
+ TURNSTILE_SECRET=dev
76
+ TURNSTILE_SITE_KEY=1x00000000000000000000AA
77
+ MAIL_API_URL=https://outbox.invalid/mail
78
+ MAIL_API_KEY=dev
79
+ ```
80
+
81
+ ## In the browser
82
+
83
+ - `/__fabrika/dev` is the picker: the personas, another role of the vocabulary (a membership role with a scope value),
84
+ anonymous, the link pages, and the mail: what IAM sent and what reached the dev outbox. It speaks the vocabulary's `locale` and shows its `displayName`.
85
+ - Every SPA page carries a toolbar in the bottom corner: who you are, the personas to switch to (the page reloads as
86
+ that caller), the picker, the link pages, and the mail. An invitation's link accepts it; the browser is the invited
87
+ person from then on.
88
+ - The choice stays in a cookie the application never sees. The data is gone when the command stops.
89
+ - It serves this machine only (`--host` is a loopback address, `--port=0` picks a free port), answers no request
90
+ 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`.