@fabrika/app 0.6.0 → 0.8.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 (82) hide show
  1. package/package.json +10 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +58 -36
  4. package/skill/references/dev.md +11 -9
  5. package/skill/references/endpoint.md +154 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +450 -255
  10. package/skill/references/spa.md +6 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +14 -8
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +221 -79
  17. package/src/client/index.ts +3 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +168 -14
  21. package/src/contract/build.ts +1282 -619
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +79 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +142 -60
  27. package/src/contract/define.ts +92 -121
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +210 -157
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +6 -5
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/challenge.ts +203 -47
  51. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  52. package/src/implement/dispatch.ts +831 -409
  53. package/src/implement/egress.ts +84 -0
  54. package/src/implement/faults.ts +11 -1
  55. package/src/implement/index.ts +3 -2
  56. package/src/implement/invocation.ts +135 -35
  57. package/src/implement/params.ts +88 -0
  58. package/src/implement/principal.ts +7 -0
  59. package/src/implement/public.ts +167 -0
  60. package/src/implement/tokens.ts +219 -0
  61. package/src/implement/types.ts +24 -3
  62. package/src/implement/urls.ts +78 -0
  63. package/src/implement/verifiers.ts +299 -0
  64. package/src/implement/webhook.ts +136 -55
  65. package/src/implement/workload.ts +16 -14
  66. package/src/index.ts +3 -0
  67. package/src/jobs/mail.ts +98 -10
  68. package/src/jobs/runner.ts +103 -71
  69. package/src/jobs/scrub.ts +4 -1
  70. package/src/jobs/types.ts +19 -2
  71. package/src/react/hooks.ts +2 -2
  72. package/src/react/provider.tsx +2 -2
  73. package/src/testing/challenge.ts +35 -1
  74. package/src/testing/dev.ts +2 -2
  75. package/src/testing/endpoint-access.ts +168 -128
  76. package/src/testing/kit.ts +90 -25
  77. package/src/testing/policy-isolation.ts +55 -29
  78. package/src/testing/proxy.ts +6 -2
  79. package/src/testing/sample-file.ts +30 -0
  80. package/src/client/files.ts +0 -135
  81. package/src/contract/file-schemas.ts +0 -25
  82. package/src/files/collections.ts +0 -848
@@ -10,16 +10,16 @@ Every piece lets in someone who is not signed in, so ask the human before you bu
10
10
  visitor do. The visitor never reaches a handler without a credential:
11
11
 
12
12
  1. **A public page** (`c.spa('/', { access: { public: true } })`) holds the form. It reads no data.
13
- 2. **A form** appends one row to its inbox table and answers `{ result: { received: true } }`, for a new and a known
14
- address alike. It runs no handler: a handler would give every anonymous request a transaction, and a different
15
- answer for a known address would tell a stranger who registered.
16
- 3. **A cron job** drains the inbox, creates the record, issues a link bound to it (`issues`), and sends the e-mail
17
- through the installation's transport (`mailer: 'installation'`, `ctx.mail`). Each form kicks it (`then: 'intake'`),
18
- so it runs within seconds of a submission; its schedule is the recovery. It is the outbox recipe of
13
+ 2. **A public procedure** (`access: { public: true }`) inserts one row into an application table and answers `{}`, for
14
+ a new and a known address alike. It reads no existing row: a different answer for a known address would tell a
15
+ stranger who registered.
16
+ 3. **A cron job** claims the registrations, creates the record, issues a link bound to it (`issues`), and sends the
17
+ e-mail through the installation's transport (`mailer: 'installation'`, `ctx.mail`). Each submission kicks it
18
+ (`kicks: ['intake']`, `ctx.kick('intake')`), so it runs within seconds; its schedule is the recovery. It is the outbox recipe of
19
19
  `references/jobs.md`: bounded batches, each record its own work with an attempt count and a backoff, a dead state
20
20
  for a record that keeps failing, an idempotency key on the e-mail, and a retry only where the transport allows it.
21
21
  4. **A link mount** serves the visitor's own record: `verify` and the record's files, under the link's key.
22
- 5. **A second form** asks for a new link. The job revokes the old one and sends a new one, within a budget per address.
22
+ 5. **A second public procedure** asks for a new link. The job revokes the old one and sends a new one, within a budget per address.
23
23
 
24
24
  ## The vocabulary, the migrations and the policies
25
25
 
@@ -37,6 +37,7 @@ export const vocabulary = defineVocabulary({
37
37
  'candidates.attach': 'Attach files to a candidate record',
38
38
  'candidates.invite': "Issue a candidate's portal link",
39
39
  'candidates.process': 'Register candidates and send their links',
40
+ 'candidates.register': 'Record an anonymous registration or resend request',
40
41
  'positions.read': 'Read the open positions',
41
42
  'positions.manage': 'Edit the open positions',
42
43
  'reasons.manage': 'Edit the reasons a decision records',
@@ -94,7 +95,8 @@ export const vocabulary = defineVocabulary({
94
95
  - `directory: 'labels'` lets every `people.list` holder read each person's label, for naming the author of a change.
95
96
  A label spells a user's address, so the approver reviews it closely.
96
97
  - The link's holder and the jobs get their actions from the link mount and each job's `actions`, not from a role.
97
- `mail.send` is no role's: only the job that sends the test e-mails holds it.
98
+ `mail.send` is no role's: only the job that sends the test e-mails holds it. `candidates.register` is no role's
99
+ either: only the public procedures hold it.
98
100
 
99
101
  ```sql db/migrations/20261013090000_intake.sql
100
102
  CREATE TYPE candidate_status AS ENUM ('new', 'screening', 'interview', 'hired', 'rejected');
@@ -120,9 +122,11 @@ CREATE TABLE candidates (
120
122
  attempts INTEGER NOT NULL DEFAULT 0,
121
123
  next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
122
124
  last_failure TEXT,
125
+ photo UUID REFERENCES fabrika_files (id),
123
126
  created_by TEXT NOT NULL,
124
127
  version INTEGER NOT NULL
125
128
  );
129
+ CREATE INDEX candidates_photo ON candidates (photo);
126
130
 
127
131
  -- The review's history: one row per decision, written in the decision's own transaction.
128
132
  CREATE TABLE candidate_events (
@@ -136,21 +140,23 @@ CREATE TABLE candidate_events (
136
140
  );
137
141
  CREATE INDEX candidate_events_candidate ON candidate_events (candidate_id);
138
142
 
139
- -- The forms' inboxes: a column per input field, of its name, except the honeypot and the challenge token, and one per
140
- -- file field, which receives the id of the file the submission carried (NULL without one). A form writes those
141
- -- columns only, so every other column has a default: id names the row for the job, received_at keeps the time of the
142
- -- submission, and a registration counts the job's failed attempts at it.
143
+ -- What the public procedures insert. A registration is a queue row the job claims: attempts counts its claims and
144
+ -- next_attempt_at is when it is due, NULL once done; last_failure names the step that failed last. photo holds the
145
+ -- file the visitor sent, or NULL. received_at keeps the time of the submission.
143
146
  CREATE TABLE registrations (
144
- id UUID NOT NULL DEFAULT gen_random_uuid(),
147
+ id UUID PRIMARY KEY,
145
148
  email TEXT NOT NULL,
146
149
  full_name TEXT NOT NULL,
147
- photo UUID,
150
+ photo UUID REFERENCES fabrika_files (id),
148
151
  received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
149
152
  attempts INTEGER NOT NULL DEFAULT 0,
150
- last_failure TEXT
153
+ next_attempt_at TIMESTAMPTZ DEFAULT now(),
154
+ last_failure TEXT,
155
+ version INTEGER NOT NULL
151
156
  );
157
+ CREATE INDEX registrations_photo ON registrations (photo);
152
158
  CREATE TABLE resend_requests (
153
- id UUID NOT NULL DEFAULT gen_random_uuid(),
159
+ id UUID PRIMARY KEY,
154
160
  email TEXT NOT NULL,
155
161
  received_at TIMESTAMPTZ NOT NULL DEFAULT now()
156
162
  );
@@ -194,53 +200,19 @@ CREATE TABLE test_mails (
194
200
  ```
195
201
 
196
202
  ```sql db/migrations/20261013090100_attachments.sql
197
- CREATE TABLE fabrika_files_attachments (
198
- id UUID PRIMARY KEY,
199
- owner_id UUID,
200
- status TEXT NOT NULL CONSTRAINT fabrika_files_attachments_status CHECK (status IN ('pending', 'live', 'quarantined', 'rejected')),
201
- object_key TEXT NOT NULL UNIQUE,
202
- size BIGINT,
203
- content_type TEXT NOT NULL,
204
- sha256 TEXT,
205
- original_name TEXT,
206
- created_by TEXT NOT NULL,
207
- created_at TIMESTAMPTZ NOT NULL,
208
- deleted_at TIMESTAMPTZ,
209
- object_version TEXT,
210
- scan_attempts INTEGER NOT NULL DEFAULT 0,
211
- scan_after TIMESTAMPTZ,
212
- FOREIGN KEY (owner_id) REFERENCES candidates (id) ON UPDATE CASCADE ON DELETE SET NULL
213
- );
214
- CREATE INDEX fabrika_files_attachments_owner ON fabrika_files_attachments (owner_id) WHERE deleted_at IS NULL;
215
- CREATE INDEX fabrika_files_attachments_removable ON fabrika_files_attachments (created_at) WHERE deleted_at IS NOT NULL OR owner_id IS NULL OR status = 'pending';
216
- CREATE INDEX fabrika_files_attachments_scan ON fabrika_files_attachments (scan_after) WHERE status = 'quarantined';
217
- ```
218
-
219
- ```sql db/migrations/20261013090150_photos.sql
220
- CREATE TABLE fabrika_files_photos (
203
+ -- The files a link holder attaches to the candidate's record: one row per file, its reference in a file column.
204
+ CREATE TABLE candidate_files (
221
205
  id UUID PRIMARY KEY,
222
- owner_id UUID,
223
- status TEXT NOT NULL CONSTRAINT fabrika_files_photos_status CHECK (status IN ('pending', 'live', 'quarantined', 'rejected')),
224
- object_key TEXT NOT NULL UNIQUE,
225
- size BIGINT,
226
- content_type TEXT NOT NULL,
227
- sha256 TEXT,
228
- original_name TEXT,
206
+ candidate_id UUID NOT NULL REFERENCES candidates (id) ON DELETE CASCADE,
207
+ file UUID NOT NULL REFERENCES fabrika_files (id),
229
208
  created_by TEXT NOT NULL,
230
- created_at TIMESTAMPTZ NOT NULL,
231
- deleted_at TIMESTAMPTZ,
232
- object_version TEXT,
233
- scan_attempts INTEGER NOT NULL DEFAULT 0,
234
- scan_after TIMESTAMPTZ,
235
- FOREIGN KEY (owner_id) REFERENCES candidates (id) ON UPDATE CASCADE ON DELETE SET NULL
209
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
236
210
  );
237
- CREATE INDEX fabrika_files_photos_owner ON fabrika_files_photos (owner_id) WHERE deleted_at IS NULL;
238
- CREATE INDEX fabrika_files_photos_removable ON fabrika_files_photos (created_at) WHERE deleted_at IS NOT NULL OR owner_id IS NULL OR status = 'pending';
239
- CREATE INDEX fabrika_files_photos_scan ON fabrika_files_photos (scan_after) WHERE status = 'quarantined';
211
+ CREATE INDEX candidate_files_candidate ON candidate_files (candidate_id);
212
+ CREATE INDEX candidate_files_file ON candidate_files (file);
240
213
  ```
241
214
 
242
- The file tables are `fileTableSql('attachments', { table: 'candidates', key: { column: 'id', type: 'uuid' }, chain: [] })`
243
- and the same for `photos` (`references/files.md`): a top-level owner has no chain.
215
+ A file column is a `UUID` that references `fabrika_files (id)`, with an index on it (`references/files.md`).
244
216
 
245
217
  ```ts src/db/tables.ts
246
218
  import { defineTables } from '@fabrika/db/tables'
@@ -260,8 +232,10 @@ export const tables = defineTables(vocabulary, columns)({
260
232
  update: ['verified_at'],
261
233
  version: 'version',
262
234
  createdBy: 'created_by',
235
+ files: { photo: 'photos' },
263
236
  columns: {
264
237
  status: { write: 'candidates.review' },
238
+ photo: { write: 'candidates.attach' },
265
239
  link_state: { read: 'candidates.process' },
266
240
  link_id: { read: 'candidates.process' },
267
241
  link_seal: { read: 'candidates.process' },
@@ -279,8 +253,40 @@ export const tables = defineTables(vocabulary, columns)({
279
253
  insert: ['status', 'note', 'reason_id'],
280
254
  createdBy: 'created_by',
281
255
  },
282
- registrations: { scope: { inbox: 'register', read: 'candidates.process' } },
283
- resend_requests: { scope: { inbox: 'resend', read: 'candidates.process' } },
256
+ candidate_files: {
257
+ scope: { child: 'candidates' },
258
+ actions: {
259
+ read: 'candidates.read',
260
+ insert: 'candidates.attach',
261
+ delete: 'candidates.review',
262
+ },
263
+ insert: ['file'],
264
+ files: { file: 'attachments' },
265
+ createdBy: 'created_by',
266
+ },
267
+ registrations: {
268
+ scope: { app: 'registrations of visitors, until the intake job takes them' },
269
+ actions: {
270
+ read: 'candidates.process',
271
+ insert: 'candidates.register',
272
+ update: 'candidates.process',
273
+ delete: 'candidates.process',
274
+ },
275
+ insert: ['email', 'full_name', 'photo'],
276
+ update: ['last_failure'],
277
+ files: { photo: 'photos' },
278
+ queue: { attempts: 'attempts', due: 'next_attempt_at', maxAttempts: 5 },
279
+ version: 'version',
280
+ },
281
+ resend_requests: {
282
+ scope: { app: 'requests for a new link, until the intake job takes them' },
283
+ actions: {
284
+ read: 'candidates.process',
285
+ insert: 'candidates.register',
286
+ delete: 'candidates.process',
287
+ },
288
+ insert: ['email'],
289
+ },
284
290
  positions: {
285
291
  scope: { app: 'the open positions an HR lead edits' },
286
292
  actions: {
@@ -320,19 +326,20 @@ export const tables = defineTables(vocabulary, columns)({
320
326
  })
321
327
  ```
322
328
 
323
- - `{ inbox: '<form>', read }` is a form's table: only that form appends to it, and only a site that holds `read` and
324
- declares the reach reads it. No site writes it: `unscoped: { registrations: 'write' }` is refused.
329
+ - `registrations` and `resend_requests` are what the public procedures insert, with `candidates.register`, which no
330
+ role grants. A public procedure inserts and reads back nothing; only the job, with `candidates.process`, reads them.
331
+ - `queue` makes `registrations` a queue table: the job's `claim` leases due rows, counts `attempts`, and moves
332
+ `next_attempt_at` by the backoff; a row claimed `maxAttempts` times stays, no longer due, for an operator.
333
+ - `files: { photo: 'photos' }` makes `photo` a file column of the kind `photos`. The claim reads it with the table's read
334
+ action, so the job may write the same reference into `candidates.photo`, whose own write action is `candidates.attach`.
325
335
  - `{ app: '<why>' }` is an application table: its rows belong to no candidate. An endpoint declared
326
336
  `access: { action, app: true }` reads and writes it through `ctx.db.app`, with the action the operation names. An
327
337
  endpoint `on`, `in` or `over` a candidate reads it, read-only and every row, where its action is the table's read
328
338
  action: `decide` reads `reasons` with `candidates.review`. A position or a reason is deactivated, never deleted, so
329
339
  neither table has a delete action. `candidate_exports` is appended with the export's own action, `candidates.export`,
330
340
  and read with `exports.read`. `test_mails` is appended with `mail.test` and read only by the job's `mail.send`.
331
- - The form writes each input field to the column of the same name: `full_name`, never `fullName`. tsc refuses another
332
- name at the form's `appendTo`, and the checker refuses it too. A `ctx.sql` row still reads it as `fullName`.
333
- - The form writes nothing else, so an inbox records the time of a submission only in a `DEFAULT now()` column
334
- (`received_at`). The job's own `now()` is later: seconds after a kick, up to a minute when the schedule recovers it.
335
- An inbox's `id` is a `DEFAULT gen_random_uuid()` for the same reason: the job takes and counts each row by it.
341
+ - A registration records the time of the submission in a `DEFAULT now()` column (`received_at`). The job's own
342
+ `now()` is later: seconds after a kick, up to a minute when the schedule recovers it.
336
343
  - The delivery columns are read only with `candidates.process`, so neither HR nor the link's holder sees them.
337
344
 
338
345
  ## The contract
@@ -347,13 +354,6 @@ export const CandidateStatus = z.enum([
347
354
  'hired',
348
355
  'rejected',
349
356
  ])
350
- export const Register = z.strictObject({
351
- email: z.email().max(200),
352
- full_name: z.string().min(1).max(200),
353
- website: z.optional(z.string().max(200)),
354
- challengeToken: z.string().max(4096),
355
- })
356
- export const Resend = z.strictObject({ email: z.email().max(200) })
357
357
  export const Me = z.strictObject({
358
358
  name: z.string(),
359
359
  email: z.string(),
@@ -369,56 +369,26 @@ import { intake } from './src/intake/endpoints'
369
369
  import { portal } from './src/portal/endpoints'
370
370
  import { positions } from './src/positions/endpoints'
371
371
  import { reasons } from './src/reasons/endpoints'
372
- import { Register, Resend } from './src/schemas'
373
372
  import { settings, testMails } from './src/settings/endpoints'
373
+ import { visitors } from './src/visitors/endpoints'
374
374
 
375
375
  export const contract = app.contract((c) => ({
376
376
  site: c.spa('/', { dir: 'dist/site', access: { public: true } }),
377
- register: c.form('/register', {
378
- input: Register,
379
- appendTo: 'registrations',
380
- maxBytes: 4096,
381
- honeypot: 'website',
382
- challenge: {
383
- provider: 'turnstile',
384
- secret: 'TURNSTILE_SECRET',
385
- siteKey: 'TURNSTILE_SITE_KEY',
386
- },
387
- files: { photo: { collection: 'photos' } },
388
- then: 'intake',
389
- }),
390
- resend: c.form('/resend', {
391
- input: Resend,
392
- appendTo: 'resend_requests',
393
- maxBytes: 1024,
394
- then: 'intake',
395
- }),
396
377
  portal,
397
- api: c.rpc('/api', { candidates, positions, reasons, settings }),
378
+ api: c.rpc('/api', { visitors, candidates, positions, reasons, settings }),
398
379
  hr: c.spa('/hr', {
399
380
  dir: 'dist/hr',
400
381
  access: { action: 'candidates.read', over: 'candidate' },
401
382
  }),
402
383
  files: {
403
384
  attachments: c.files({
404
- owner: 'candidates',
405
- read: 'candidates.read',
406
- write: 'candidates.attach',
407
- delete: 'candidates.review',
408
- maxBytes: 10485760,
409
385
  types: ['application/pdf'],
410
- maxPerOwner: 5,
411
- link: true,
386
+ maxBytes: 10485760,
412
387
  scan: true,
413
388
  }),
414
389
  photos: c.files({
415
- owner: 'candidates',
416
- read: 'candidates.read',
417
- write: 'candidates.attach',
418
- delete: 'candidates.review',
419
- maxBytes: 15728640,
420
390
  types: ['image/jpeg', 'image/png', 'image/webp'],
421
- maxPerOwner: 1,
391
+ maxBytes: 15728640,
422
392
  scan: true,
423
393
  }),
424
394
  },
@@ -427,59 +397,153 @@ export const contract = app.contract((c) => ({
427
397
  }))
428
398
  ```
429
399
 
400
+ ```ts src/visitors/endpoints.ts
401
+ import { honeypot } from '@fabrika/app'
402
+ import { file } from '@fabrika/app/contract'
403
+ import { z } from 'zod/v4'
404
+ import { app } from '../app-kit'
405
+
406
+ const Register = z.strictObject({
407
+ email: z.email().max(200),
408
+ fullName: z.string().min(1).max(200),
409
+ website: z.optional(z.string().max(200)),
410
+ challengeToken: z.string().max(4096),
411
+ photo: z.optional(file('photos')),
412
+ })
413
+ const Resend = z.strictObject({ email: z.email().max(200) })
414
+ const Empty = z.strictObject({})
415
+
416
+ /** Each inserts one row and answers the same, whatever the database holds: no answer tells who registered. */
417
+ export const visitors = app.group({}, (r) => ({
418
+ register: r.mutation({
419
+ input: Register,
420
+ output: Empty,
421
+ access: { public: true },
422
+ actions: ['candidates.register'],
423
+ admission: {
424
+ challenge: { provider: 'altcha', secret: 'ALTCHA_HMAC_KEY' },
425
+ rate: { perClient: 100, perEndpoint: 100, bytesPerHour: 536870912 },
426
+ },
427
+ kicks: ['intake'],
428
+ })
429
+ .handle(async ({ input, ctx }) => {
430
+ if (honeypot(input.website)) return {}
431
+ await ctx.db.app('candidates.register').registrations.insert({
432
+ email: input.email,
433
+ full_name: input.fullName,
434
+ photo: input.photo ?? null,
435
+ })
436
+ ctx.kick('intake')
437
+ return {}
438
+ }),
439
+ resend: r.mutation({
440
+ input: Resend,
441
+ output: Empty,
442
+ access: { public: true },
443
+ actions: ['candidates.register'],
444
+ limits: { maxBytes: 1024 },
445
+ kicks: ['intake'],
446
+ })
447
+ .handle(async ({ input, ctx }) => {
448
+ await ctx.db.app('candidates.register').resend_requests.insert({
449
+ email: input.email,
450
+ })
451
+ ctx.kick('intake')
452
+ return {}
453
+ }),
454
+ }))
455
+ ```
456
+
430
457
  - **The public page.** `access: { public: true }` serves the SPA to anyone, without a token, and takes no other key.
431
458
  At `/` it answers every `GET` that no more specific endpoint serves, so the HR SPA (`/hr`) and the link's SPA (`/p`)
432
459
  keep their own. The approver sees it as "for anyone, without signing in". Put nothing on it that only members may
433
- read: the page calls no procedure.
434
- - **The form's abuse control.** `honeypot` names an optional string field the page hides: a filled one gets the same
435
- answer and writes no row. `challenge` makes the framework verify the Turnstile token in `challengeToken` before it
436
- writes the row (a failed token 400, an unreachable provider 503, no row either way) and adds Turnstile's origin to
437
- the CSP. `TURNSTILE_SECRET` is an application variable the human sets; without it the application does not boot.
438
- `siteKey` names the variable with the widget's public site key, which every SPA mount serves its pages; without it
439
- the application does not boot either. Another public value a page needs per environment (a link target) goes in
440
- the SPA mount's own list: `c.spa('/', { …, public: ['SIGNUP_URL'] })`. Both reach the page in
441
- `<prefix>/__fabrika-config.json` as `public: { <VARIABLE>: <value> }`, and the approver sees each name. Never name
442
- a secret there: the contract refuses a variable that is both.
443
- `rate: { perClient, perForm }` (per minute, 10 and 100 by default) answers 429 past either limit.
444
- - **A form on someone else's page.** A form served from this application's own SPA needs nothing. A form a partner's
445
- page or a separate marketing site submits needs `crossOrigin`: `true` for the origins an operator lists for the
446
- environment in Control, `'*'` for any site (an embeddable widget). The proxy answers the browser's preflight and
447
- the page can read the constant answer. Keep the honeypot, the challenge and the rate limit: `'*'` invites every site.
460
+ read: the page calls only the public procedures.
461
+ - **A schema with a file field** stays in the slice's `endpoints.ts`, as `Register` does here. `src/schemas.ts` is
462
+ loaded by the SPA and by `queries.ts`, and both are refused `@fabrika/app/contract`, where `file` comes from
463
+ (`spa-server-import`, `layer-import`).
464
+ - **A public procedure** sits in a group with no callers (`app.group({}, …)`), declares `access: { public: true }`
465
+ and the `actions` it holds, and is served at `POST /api/visitors.register`. `access` says anyone may call it;
466
+ `actions` say what its handler may do. It runs as its own workload, with those actions only, whoever calls: the
467
+ approver reads "anyone may call `api.visitors.register`; it runs with `candidates.register`".
468
+ - **What its handler reaches.** `ctx.db.app('<action>')` of an application table: an insert reads back nothing (the
469
+ id is generated before the insert), a read needs the table's `read` action, and an update or a remove takes only a
470
+ row id a verified signed token of the request carries (`references/endpoint.md`). It never locks or claims a row. It
471
+ declares only `kicks` and `signs`: no `sql`, `unscoped`, `issues`, `connect`, `egress`, `binds` or mail. No role may
472
+ grant its action, so a role change cannot widen it; a table staff read too lists both actions.
473
+ - **Constant work.** Answer and work the same for a new and a known visitor (`{}` here, one inserted row): an answer
474
+ or a delay that depends on what exists tells a stranger who registered. The job decides later.
475
+ - **`ctx.client`** is `{ ip, userAgent }` as the proxy saw them, for consent evidence in a column of the row; `ip` is
476
+ `null` on Zerops. Never decide on either.
477
+ - **Its abuse control.** `honeypot(input.website)` is true when the field the page hides is filled: answer the same
478
+ and write no row. `admission.challenge: { provider: 'altcha', secret: 'ALTCHA_HMAC_KEY' }` is a proof of work the
479
+ application issues and verifies itself: the framework serves `GET /__fabrika/challenge/<action>` (the endpoint id
480
+ with `_` for `.`, `api_visitors_register`, or the challenge's own `action`), from the procedure's origins with a
481
+ buckets of its own (30 per client and 600 in all per minute, and at least twice the procedure's), and verifies the solved challenge in the input field
482
+ `challengeToken` before the handler runs: a failed, expired or replayed one, or one issued for another host or
483
+ procedure, is 400 `challenge_failed`, no row. No request leaves the installation, no third party sees the visitor,
484
+ and there is no site key. `ALTCHA_HMAC_KEY` is an application variable the human sets to a long random value
485
+ (`openssl rand -base64 32`); without it, or with one under 32 characters, the application does not boot: every
486
+ challenge publishes a signature a short key could be guessed from, and the key forges every challenge. `cost`
487
+ (PBKDF2 iterations per attempt, 5000 by default, 1000 to 18000) and `maxAgeMs` (how long a challenge may be solved
488
+ and sent, ten minutes by default, a minute to an hour) are the contract's, and the approver sees both. A browser
489
+ makes up to 10000 attempts, about 5000 on average, a few seconds on a phone at the default; the build refuses a
490
+ `cost` a slow phone would not solve within the widget's 90 s or half of `maxAgeMs`. The CSP gains `worker-src 'self' blob:`, where the widget solves.
491
+ - **Turnstile instead.** `challenge: { provider: 'turnstile', secret: 'TURNSTILE_SECRET', siteKey: 'TURNSTILE_SITE_KEY' }`
492
+ verifies Cloudflare's widget at Cloudflare (an unreachable provider is 503), adds Cloudflare's origin to the CSP, and
493
+ serves the site key to every SPA mount's pages. Use it only when the human asks for it: it needs a Cloudflare
494
+ account and a widget per hostname, and sends every visitor to a third party. Another public value a page needs per
495
+ environment (a link target) goes in the SPA mount's `browserConfig` list: `c.spa('/', { …, browserConfig: ['SIGNUP_URL'] })`.
496
+ It reaches the page in `<prefix>/__fabrika-config.json` as `public: { <VARIABLE>: <value> }`, and the approver sees
497
+ each name. Never name a secret there: the contract refuses a variable that is both.
498
+ `admission.rate: { perClient, perEndpoint }` (per minute, 10 and 100 by default for a public mutation) answers 429
499
+ past either limit; `null` turns one off. A public query counts nothing unless it declares a bucket, since each
500
+ bucket is a database write per call: a page of twenty images costs no write. All public procedures of an instance
501
+ hold at most a quarter of its database connections together; past that the answer is 503, and staff keep the rest.
502
+ `limits: { maxBytes, timeoutMs }` bounds the body (64 KiB by default, plus each file field's files) and the handler
503
+ (10 s). With file fields the JSON `input` part takes up to that limit, and the whole body, files included, stays
504
+ within it.
505
+ - **The order of a public request**, each refusal with its answer: the origin rule (403), the in-flight share (503),
506
+ every bucket (429), the bounded body and the strict input with its signed tokens (413, 400), the workload token (503
507
+ while the platform's identity service is down), the challenge (400; Turnstile's 503 when Cloudflare is unreachable), the staged file parts, then the handler, the
508
+ commit and the kicks.
509
+ - **A form on someone else's page.** A page served from this application's own origin needs nothing. A partner's page
510
+ or a separate marketing site needs `admission.origins`: `'listed'` for the origins an operator lists for the
511
+ environment in Control, `'any'` for any site (an embeddable widget). Keep the honeypot, the challenge and the rate
512
+ limit: `'any'` invites every site. Such a page is not yours to change in step with the application, so give it a URL
513
+ and a widget action that do not follow your group names: `http: { path: '/register' }` and
514
+ `challenge: { …, action: 'register' }`, so its challenge is at `/__fabrika/challenge/register`. The path lies outside
515
+ every mount's prefix (the root SPA's excepted). That page bundles the `altcha` widget itself, points its `challenge`
516
+ attribute at the application's origin and that path, and needs `worker-src blob:` in its own CSP.
448
517
  - **Zerops shares one client address.** Until the platform reads the visitor's address behind the Zerops balancer,
449
- every visitor counts as one client there, so `perClient` acts as a second per-form limit: set it to the form's
450
- expected peak, never lower it to stop a flood. The challenge is the defense that does not depend on the address.
451
- - **Counting clicks** on a public page has no primitive. The pattern is an anonymous form without a challenge whose
452
- inbox gets one row per click, posted with `fetch(…, { keepalive: true })`. Know both limits before you offer it:
453
- anyone can inflate the counts (the rate limits bound only how fast), and nothing drains the inbox but a job you
518
+ every visitor counts as one client there, so `perClient` acts as a second per-endpoint limit: set it to the expected
519
+ peak, never lower it to stop a flood. The challenge is the defense that does not depend on the address.
520
+ - **Counting clicks** on a public page has no primitive. The pattern is a public procedure without a challenge that
521
+ inserts one row per click, posted with `fetch(…, { keepalive: true })`. Know both limits before you offer it:
522
+ anyone can inflate the counts (the rate limits bound only how fast), and nothing reads the rows but a job you
454
523
  write. Ask the human first; usually the application should not count clicks.
455
524
  - **Other origins** the pages load (an image host, an API the browser calls) go in the contract's `csp` section:
456
525
  `csp: { scriptSrc: [{ origin: 'https://…', purpose: '…' }] }`, beside `connectSrc`, `imgSrc`, `frameSrc`,
457
526
  `styleSrc`, `fontSrc` and `frameAncestors`: exact `https://` origins, at most 16 each. Each new origin is a widening the approver sees.
458
- - **Scanned files.** A visitor's upload waits `quarantined` until ClamAV answers clean (`references/files.md`): its
459
- download answers 409 until then. On Zerops the deploy service needs `verticalAutoscaling: { minRam: 2 }`.
460
- - **A photo with the registration.** `files: { photo: { collection: 'photos' } }` lets the form carry one file beside
461
- its input; `required: true` refuses a submission without it. The form becomes `multipart/form-data` (the page's code
462
- is below) and must declare a challenge; each field lands in a collection that has `scan: true`, whose `types`,
463
- `maxBytes` and `maxPerOwner` bound the file, while the form's `maxBytes` bounds only its input. The inbox has a
464
- column named like the field (`photo UUID`), which receives the file's id, or `NULL` when the visitor sent none. Until
465
- the job attaches it, the file is owned by nobody and nobody can read it; one the job never attaches (a known address,
466
- a registration that stays dead) is deleted seven days after the submission. The approver sees "Form `register` takes
467
- a file `photo` from anyone into files `photos`". A file the collection refuses (a type it does not admit, bytes
468
- that are not that type, too large) is 415 or 413 after the challenge was spent, so the page checks the type and the
469
- size before it submits, and solves the challenge again after a refusal.
470
- While no file is `required`, the form still takes the JSON body it took before (every file column `NULL`): deploy the
471
- application first, then switch each page to multipart. A form with files accepts 20 submissions a minute unless
472
- `rate.perForm` says otherwise, and its files write at most 512 MiB an hour in all (`rate: { bytesPerHour }` to raise
473
- it, never below the largest file): each file reserves its collection's `maxBytes` first, so with a 15 MiB photo about
474
- 34 maximal photos fit in an hour, more of the usual 2–5 MB ones; past it the form answers 429 until the hour ends.
475
- Size the budget for the expected peak: a flood that spends it costs that hour's photos, not the storage.
476
- - **The kick.** `then: 'intake'` makes each accepted submission start a run of the job within seconds, once its row
477
- committed, under the job's own key. The visitor's answer is the same constant and never waits for the run. Kicks
478
- coalesce on the job's lease (at most one run and one pending, however many visitors submit), so a flood adds no
479
- work: the run still drains the inbox in batches of 50, within each address's budget. A kick carries nothing; the
480
- job reads its inbox.
481
-
482
- ## The job: inbox → record → link → e-mail
527
+ - **Scanned files.** A visitor's upload waits `quarantined` until ClamAV answers clean (`references/files.md`): a file
528
+ answer is 409 until then. On Zerops the deploy service needs `verticalAutoscaling: { minRam: 2 }`.
529
+ - **A photo with the registration.** `photo: z.optional(file('photos'))` lets the visitor send one file beside the
530
+ input; the client then sends the body as multipart. A public file field needs a challenge, `bytesPerHour` and a kind
531
+ with `scan: true`, whose `types` and `maxBytes` bound the file. The handler writes the reference into
532
+ `registrations.photo`, and the job writes the same reference into `candidates.photo`: the file is never copied. A
533
+ file no column references any more (a known address, a registration an operator deleted) is deleted by the
534
+ framework's sweep. A file the kind refuses (a type it does not admit, bytes that are not that type, too large) is
535
+ 415 or 413 after the challenge was spent, so the page checks the type and the size before it submits, and solves
536
+ the challenge again after a refusal. The procedure's bodies take at most `bytesPerHour` in all (512 MiB here).
537
+ Each request is charged its declared `Content-Length` before its body is read, which a browser sends for a form,
538
+ or the whole body limit when it declares none, so with a 15 MiB photo about 34 maximal requests fit in an hour;
539
+ past it the procedure answers 429 until the hour ends. Size the budget for the expected peak: a flood that spends
540
+ it costs that hour's photos, not the storage.
541
+ - **The kick.** `kicks: ['intake']` and `ctx.kick('intake')` start a run of the job within seconds, once the row
542
+ committed, under the job's own key. The visitor's answer never waits for the run. Kicks coalesce on the job's lease
543
+ (at most one run and one pending, however many visitors submit), so a flood adds no work: the run still claims in
544
+ batches of 50, within each address's budget. A kick carries nothing; the job reads its tables.
545
+
546
+ ## The job: registration → record → link → e-mail
483
547
 
484
548
  ```ts src/links.ts
485
549
  import { defineDeps } from '@fabrika/app/implement'
@@ -553,15 +617,8 @@ import { z } from 'zod/v4'
553
617
  import { app } from '../app-kit'
554
618
  import { links } from '../links'
555
619
 
556
- const Queued = z.strictObject({
557
- id: z.string(),
558
- email: z.string(),
559
- fullName: z.string(),
560
- photo: z.nullable(z.string()),
561
- })
562
620
  const Ask = z.strictObject({ email: z.string() })
563
621
  const Id = z.strictObject({ id: z.string() })
564
- const Attempts = z.strictObject({ attempts: z.number() })
565
622
  const State = z.strictObject({ linkState: z.string() })
566
623
  const Due = z.strictObject({
567
624
  id: z.string(),
@@ -593,7 +650,6 @@ export const intake = app.cron('* * * * *', {
593
650
  'candidates.verify',
594
651
  'candidates.attach',
595
652
  ],
596
- scope: 'app',
597
653
  retries: 0,
598
654
  sql: 'write',
599
655
  issues: 'portal',
@@ -616,47 +672,42 @@ export const intake = app.cron('* * * * *', {
616
672
  await failed(error)
617
673
  }
618
674
  }
619
- // A registration becomes a candidate once; the form told the visitor nothing either way.
620
- const queued = await ctx.transaction((tx) =>
621
- tx.sql(Id)`
622
- select id from registrations where attempts < ${MAX_ATTEMPTS}
623
- order by received_at, id limit ${BATCH}`
624
- )
625
- for (const { id } of queued) {
675
+ // A registration becomes a candidate once; the procedure told the visitor nothing either way.
676
+ const claimed = await ctx.db.app('candidates.process').registrations.claim({
677
+ limit: BATCH,
678
+ })
679
+ for (const row of claimed) {
626
680
  await eachRecord(() =>
627
681
  ctx.transaction(async (tx) => {
628
- // Claimed where it is consumed: a row other work holds is skipped, not waited for.
629
- const [row] = await tx.sql(Queued)`
630
- select id, email, full_name, photo from registrations
631
- where id = ${id} and attempts < ${MAX_ATTEMPTS}
632
- for update skip locked`
633
- if (row === undefined) return
634
- await tx.sql(Id)`
635
- delete from registrations where id = ${row.id} returning id`
636
682
  const [created] = await tx.sql(Id)`
637
683
  insert into candidates (id, email, name, created_by, version)
638
- values (${crypto.randomUUID()}, ${row.email}, ${row.fullName},
684
+ values (${crypto.randomUUID()}, ${row.email}, ${row.full_name},
639
685
  ${ctx.principal.id}, 1)
640
686
  on conflict (email) do nothing
641
687
  returning id`
642
- // The photo becomes the new candidate's in this transaction; a known address gets none.
688
+ // The photo's reference moves to the new candidate in this transaction; a known address gets none.
643
689
  if (created !== undefined && row.photo !== null) {
644
690
  const candidate = await tx.ref(
645
691
  'candidates.attach',
646
692
  'candidate',
647
693
  created.id,
648
694
  )
649
- await tx.files('photos').attach(candidate, row.photo)
695
+ await tx.db.on(candidate).update({ photo: row.photo }, {
696
+ expectVersion: 1,
697
+ })
650
698
  }
699
+ await tx.db.on(row.ref).remove({ expectVersion: row.version })
651
700
  }), async () => {
652
- const [counted] = await ctx.transaction((tx) =>
653
- tx.sql(Attempts)`
654
- update registrations
655
- set attempts = attempts + 1, last_failure = 'register'
656
- where id = ${id} returning attempts`
701
+ // The claim counted the attempt and set the backoff; the row keeps the step that failed.
702
+ await ctx.transaction((tx) =>
703
+ tx.db.on(row.ref).update({ last_failure: 'register' }, {
704
+ expectVersion: row.version,
705
+ })
657
706
  )
658
- if (counted !== undefined && counted.attempts >= MAX_ATTEMPTS) {
659
- console.error(`intake: registration ${id} is dead; an operator decides`)
707
+ if (row.attempts >= MAX_ATTEMPTS) {
708
+ console.error(
709
+ `intake: registration ${row.id} is dead; an operator decides`,
710
+ )
660
711
  }
661
712
  })
662
713
  }
@@ -815,7 +866,7 @@ The job keeps eight invariants. Keep them when you change it:
815
866
  deliveries; the next run takes the rest. Each record's work runs in `eachRecord`: a failure (a row the insert refuses,
816
867
  a refused recipient, an e-mail past `SEND_TIMEOUT_MS`, IAM down) adds one to the record's `attempts`, keeps the step
817
868
  in `last_failure`, and the run goes on with the next record, so one bad record stops nothing else. A record that failed `MAX_ATTEMPTS` times is
818
- dead: a registration stays in its inbox and the batch skips it, a delivery is `failed`; the job logs each once, by
869
+ dead: a registration stays in its table, no longer due, a delivery is `failed`; the job logs each once, by
819
870
  id, never by address. An operator decides: fix the row, or delete it. A resend revives a `failed` delivery, within
820
871
  the budget. A failure of an ended attempt (`ctx.signal` aborted) is the attempt's, not the record's: it is thrown,
821
872
  and the next run meets the record as it is. So each e-mail gets its own deadline inside the attempt's: `ctx.mail.send`
@@ -830,8 +881,9 @@ The job keeps eight invariants. Keep them when you change it:
830
881
  lost answer) sends it again only when `ctx.mail.capabilities.idempotent` holds, and otherwise leaves it dead for a
831
882
  person, because the e-mail may have arrived. A failure the transport says did not send it (`outcome: 'not_sent'`)
832
883
  returns the row to `issued`.
833
- - **A record is claimed where it is consumed.** Each registration is read again `for update skip locked` in the
834
- transaction that deletes it and inserts the candidate, the resend batch is `for update skip locked` in the delete that
884
+ - **A record is claimed where it is consumed.** `claim` leases each registration in a short transaction of its own and
885
+ skips a row other work holds; the job then removes it and inserts the candidate in one transaction, under the
886
+ version the claim answered. A failure leaves the row due again after the claim's backoff. The resend batch is `for update skip locked` in the delete that
835
887
  takes it, and the reservation skips a candidate row other work holds. A row someone else locks (an operator's
836
888
  session, an HR request) waits for a later run and blocks nothing behind it.
837
889
  - **The budget is charged first.** Each delivery reserves one e-mail of the address's daily budget, in its own
@@ -861,10 +913,11 @@ More on the job:
861
913
  links it issued. Its `actions` must hold the mount's `issuedBy` and every grant of the mount, or the contract is
862
914
  refused: IAM lets an issuer delegate only what it holds. The link is bound to one candidate (the `Ref`), and the
863
915
  approver sees the job's `issues` marked "needs closer review".
864
- - **`sql: 'write'`** is the declared reach into the inboxes: `delete … where id = …` takes each row exactly once, and
865
- a refused insert rolls its delete back with it. **A job writes no scope fragment**: it is app-wide (`scope: 'app'`
866
- is the only scope), so `tx.sql.scope` of its `Within` is every row, and an inbox has no scope at all.
867
- - **The latency.** The forms' kick starts a run within seconds of a submission, so the e-mail leaves seconds later,
916
+ - **`sql: 'write'`** is the declared reach for the candidate's insert (`on conflict (email) do nothing`), the resend
917
+ requests and the delivery state. A file column is written only through `tx.db`, so the photo's reference goes into
918
+ `candidates.photo` through `tx.db.on(ref).update`. **A job writes no scope fragment**: it is application-wide, so
919
+ `tx.sql.scope` of its `Within` is every row, and an application table has no scope at all.
920
+ - **The latency.** The procedures' kick starts a run within seconds of a submission, so the e-mail leaves seconds later,
868
921
  while the address has an e-mail of its daily budget left; a spent one waits until a day after the window's first
869
922
  e-mail. Keep the schedule (`* * * * *`): it is the recovery path. A kicked run that dies (a crash, a deploy, a
870
923
  timeout) leaves its rows, and the next scheduled run finishes them, within a minute. `retries: 0`: that run is the
@@ -884,9 +937,26 @@ More on the job:
884
937
  ## The visitor's link: verify once, attach files
885
938
 
886
939
  ```ts src/portal/endpoints.ts
940
+ import { ConflictError, NotFoundError } from '@fabrika/app'
941
+ import { file } from '@fabrika/app/contract'
942
+ import { z } from 'zod/v4'
887
943
  import { app } from '../app-kit'
888
944
  import { Me, Verified } from '../schemas'
889
945
 
946
+ const Attach = z.strictObject({ file: file('attachments') })
947
+ const Attached = z.strictObject({ id: z.uuid() })
948
+ const Document = z.strictObject({ id: z.uuid() })
949
+ const Documents = z.strictObject({
950
+ items: z.array(z.strictObject({
951
+ id: z.uuid(),
952
+ name: z.nullable(z.string()),
953
+ status: z.string(),
954
+ })),
955
+ })
956
+
957
+ /** The files one candidate keeps. */
958
+ const MAX_FILES = 5
959
+
890
960
  export const portal = app.link('/p', {
891
961
  spa: 'dist/portal',
892
962
  dimension: 'candidate',
@@ -918,6 +988,43 @@ export const portal = app.link('/p', {
918
988
  })
919
989
  return { verifiedAt: now.toISOString() }
920
990
  }),
991
+ attach: l.mutation({
992
+ input: Attach,
993
+ output: Attached,
994
+ access: { action: 'candidates.attach' },
995
+ })
996
+ .handle(async ({ input, ctx }) => {
997
+ // The lock serializes two uploads of one candidate, so the count holds.
998
+ await ctx.db.target.getForUpdate()
999
+ const held = await ctx.db.target.candidate_files.list({ limit: MAX_FILES })
1000
+ if (held.items.length >= MAX_FILES) {
1001
+ throw new ConflictError(`a candidate keeps at most ${MAX_FILES} files`)
1002
+ }
1003
+ await ctx.db.target.candidate_files.insert({ file: input.file })
1004
+ return { id: input.file.id }
1005
+ }),
1006
+ documents: l.query({
1007
+ output: Documents,
1008
+ access: { action: 'candidates.read' },
1009
+ })
1010
+ .handle(async ({ ctx }) => ({
1011
+ items: (await ctx.db.target.candidate_files.list()).items.map((row) => ({
1012
+ id: row.file.id,
1013
+ name: row.file.name,
1014
+ status: row.file.status,
1015
+ })),
1016
+ })),
1017
+ document: l.query({
1018
+ input: Document,
1019
+ output: file('attachments'),
1020
+ access: { action: 'candidates.read' },
1021
+ })
1022
+ .handle(async ({ input, ctx }) => {
1023
+ const rows = (await ctx.db.target.candidate_files.list()).items
1024
+ const row = rows.find((item) => item.file.id === input.id)
1025
+ if (row === undefined) throw new NotFoundError('no such document')
1026
+ return row.file
1027
+ }),
921
1028
  }),
922
1029
  })
923
1030
  ```
@@ -926,11 +1033,12 @@ export const portal = app.link('/p', {
926
1033
  answers the same time. IAM counts no uses: the proxy honors a token it holds without asking IAM.
927
1034
  - A link procedure acts on the link's own object: no `from`, no id in the input. A key of another candidate reaches
928
1035
  only that candidate's record, and a key whose candidate is gone answers 404.
929
- - `link: true` on the collection gives the holder's client `portal.files.attachments`: `upload(file)`, `list()` and
930
- `download(id)`, for the link's candidate only: another candidate's file id answers 404. The mount must grant the
931
- collection's `write` and `read`. HR reaches the same files through `api.files.attachments`, which names the owner,
932
- with `candidates.read`. A second link mount on candidates that must not reach the files (a one-click answer) needs
933
- `link: ['portal']` instead.
1036
+ - A file is a value in `candidate_files.file`: `attach` writes the uploaded reference with `candidates.attach`,
1037
+ `documents` lists the rows with each file's `name` and `status`, and `document` answers one file
1038
+ (`output: file('attachments')`), all for the link's candidate only: another candidate's file id answers 404. The
1039
+ holder's client calls `portal.attach({ file })` and `download(portal.document, { id })`; a key never goes into a
1040
+ URL. HR reaches the same rows through procedures of its own with `candidates.read`. A link mount reaches only the
1041
+ files its own procedures answer.
934
1042
 
935
1043
  ```ts web/portal/main.ts
936
1044
  import { createClient, RpcError, shareKey } from '@fabrika/app/client'
@@ -958,7 +1066,7 @@ export const confirm = async (status: HTMLElement): Promise<void> => {
958
1066
  export const attach = async (file: File): Promise<string> => {
959
1067
  if (portal === null) return 'This link holds no key.'
960
1068
  try {
961
- await portal.files.attachments.upload(file)
1069
+ await portal.attach({ file })
962
1070
  return 'Received: it is being checked.'
963
1071
  } catch (error) {
964
1072
  return error instanceof RpcError ? `Refused: ${error.type}` : 'Failed'
@@ -966,30 +1074,16 @@ export const attach = async (file: File): Promise<string> => {
966
1074
  }
967
1075
  ```
968
1076
 
969
- The public page reads its site key from the config the framework serves it, so one build serves every environment.
1077
+ The public page calls the public procedure through the typed client, like any other. An input with a file is sent
1078
+ as a multipart body, and one without a file as JSON.
970
1079
 
971
- ```ts web/site/config.ts
972
- import { z } from 'zod/v4'
973
-
974
- const SiteConfig = z.object({
975
- public: z.object({ TURNSTILE_SITE_KEY: z.string() }),
976
- })
977
-
978
- /** The Turnstile widget's site key, or `null` when the config is unreadable. */
979
- export const siteKey = async (): Promise<string | null> => {
980
- const response = await fetch('/__fabrika-config.json')
981
- const config = SiteConfig.safeParse(response.ok ? await response.json() : null)
982
- return config.success ? config.data.public.TURNSTILE_SITE_KEY : null
983
- }
984
- ```
1080
+ ```ts web/site/register.ts
1081
+ import { createClient } from '@fabrika/app/client'
1082
+ import { contract } from '../../fabrika.contract'
985
1083
 
986
- The public page sends the form with a plain `fetch`: a form has no typed client, and its answer never depends on the
987
- data. A form that takes files is `multipart/form-data`: the first part is named `input` and holds the input as JSON,
988
- and each file follows in a part named after its field. Let the browser write the `Content-Type` with its boundary:
989
- never set it yourself.
1084
+ const api = createClient(contract, 'api')
990
1085
 
991
- ```ts web/site/register.ts
992
- /** What the `photos` collection admits; the page checks it before it spends the challenge. */
1086
+ /** What the `photos` kind admits; the page checks it before it spends the challenge. */
993
1087
  const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp']
994
1088
  const PHOTO_MAX_BYTES = 15728640
995
1089
 
@@ -1002,32 +1096,82 @@ export const register = async (
1002
1096
  const value = data.get(name)
1003
1097
  return typeof value === 'string' ? value : ''
1004
1098
  }
1005
- const body = new FormData()
1006
- body.append(
1007
- 'input',
1008
- JSON.stringify({
1099
+ // <input type="file" name="photo" accept="image/*" capture="environment">; an empty one sends nothing.
1100
+ const sent = data.get('photo')
1101
+ const photo = sent instanceof File && sent.size > 0 ? sent : undefined
1102
+ if (
1103
+ photo !== undefined
1104
+ && (!PHOTO_TYPES.includes(photo.type) || photo.size > PHOTO_MAX_BYTES)
1105
+ ) {
1106
+ return false
1107
+ }
1108
+ try {
1109
+ await api.visitors.register({
1009
1110
  email: field('email'),
1010
- full_name: field('full_name'),
1111
+ fullName: field('full_name'),
1011
1112
  website: field('website'),
1012
1113
  challengeToken,
1013
- }),
1014
- )
1015
- // <input type="file" name="photo" accept="image/*" capture="environment">; an empty one sends nothing.
1016
- const photo = data.get('photo')
1017
- if (photo instanceof File && photo.size > 0) {
1018
- if (!PHOTO_TYPES.includes(photo.type) || photo.size > PHOTO_MAX_BYTES) {
1019
- return false
1020
- }
1021
- body.append('photo', photo, photo.name)
1114
+ photo,
1115
+ })
1116
+ return true
1117
+ } catch {
1118
+ return false
1022
1119
  }
1023
- const response = await fetch('/register', { method: 'POST', body })
1024
- return response.ok
1025
1120
  }
1026
1121
  ```
1027
1122
 
1028
- - The page renders the Turnstile widget with the site key `siteKey()` reads and `action` set to the form's mount name
1029
- (`register`), and passes the widget's token. Say "check your inbox" on success whatever the address was.
1030
- - The resend form is the same call to `/resend` with `{ email }`. It answers the same for an unknown, a verified and a
1123
+ The page renders the challenge with `@fabrika/ui/challenge`, which bundles the `altcha` widget with the page (add
1124
+ `altcha` to the dependencies: it is the kit's optional peer). `challengeUrl` gives the address the widget fetches the
1125
+ procedure's challenge from; nothing else is configured per environment.
1126
+
1127
+ ```tsx web/site/register-form.tsx
1128
+ import { challengeUrl, createClient } from '@fabrika/app/client'
1129
+ import { Challenge } from '@fabrika/ui/challenge'
1130
+ import { useState } from 'react'
1131
+ import { contract } from '../../fabrika.contract'
1132
+ import { register } from './register'
1133
+
1134
+ const api = createClient(contract, 'api')
1135
+
1136
+ export function RegisterForm() {
1137
+ const [token, setToken] = useState<string | null>(null)
1138
+ // A challenge passes once: a new key renders a fresh one after a refusal.
1139
+ const [attempt, setAttempt] = useState(0)
1140
+ const [sent, setSent] = useState(false)
1141
+ if (sent) return <p>Check your inbox.</p>
1142
+ return (
1143
+ <form
1144
+ onSubmit={async (event) => {
1145
+ event.preventDefault()
1146
+ if (token === null) return
1147
+ if (await register(event.currentTarget, token)) setSent(true)
1148
+ else setAttempt((n) => n + 1)
1149
+ }}
1150
+ >
1151
+ <input name="email" type="email" required aria-label="E-mail" />
1152
+ <input name="full_name" required aria-label="Full name" />
1153
+ <input name="website" tabIndex={-1} autoComplete="off" hidden />
1154
+ <input
1155
+ name="photo"
1156
+ type="file"
1157
+ accept="image/*"
1158
+ capture="environment"
1159
+ aria-label="Photo"
1160
+ />
1161
+ <Challenge
1162
+ key={attempt}
1163
+ url={challengeUrl(api.visitors.register)}
1164
+ onToken={setToken}
1165
+ />
1166
+ <button type="submit" disabled={token === null}>Register</button>
1167
+ </form>
1168
+ )
1169
+ }
1170
+ ```
1171
+
1172
+ - Say "check your inbox" on success whatever the address was. The widget reads its texts in English; translate them
1173
+ with `strings` (`<Challenge strings={{ label: 'Nejsem robot', verified: 'Ověřeno' }} … />`).
1174
+ - The resend form calls `api.visitors.resend({ email })`. It answers the same for an unknown, a verified and a
1031
1175
  rate-limited address; the job decides.
1032
1176
  - Build each SPA into its `dir` (`dist/site`, `dist/portal`, `dist/hr`): one `Bun.build` per `index.html` in
1033
1177
  `build-web.ts`.
@@ -1059,8 +1203,14 @@ export const stageOf = (
1059
1203
  ```
1060
1204
 
1061
1205
  ```ts src/candidates/endpoints.ts
1062
- import { BadRequestError, ConflictError, fromPage, fromRow } from '@fabrika/app'
1063
- import { attachment } from '@fabrika/app/contract'
1206
+ import {
1207
+ BadRequestError,
1208
+ ConflictError,
1209
+ fromPage,
1210
+ fromRow,
1211
+ NotFoundError,
1212
+ } from '@fabrika/app'
1213
+ import { attachment, file } from '@fabrika/app/contract'
1064
1214
  import { z } from 'zod/v4'
1065
1215
  import { app } from '../app-kit'
1066
1216
  import { CandidateStatus } from '../schemas'
@@ -1094,6 +1244,14 @@ const ListCandidates = z.strictObject({
1094
1244
  after: Cursor.optional(),
1095
1245
  })
1096
1246
  const ById = z.strictObject({ id: z.uuid() })
1247
+ const FileOf = z.strictObject({ id: z.uuid(), file: z.uuid() })
1248
+ const Files = z.strictObject({
1249
+ items: z.array(z.strictObject({
1250
+ id: z.uuid(),
1251
+ name: z.nullable(z.string()),
1252
+ status: z.string(),
1253
+ })),
1254
+ })
1097
1255
  const HistoryOf = z.strictObject({ id: z.uuid(), after: Cursor.optional() })
1098
1256
  const Decide = z.strictObject({
1099
1257
  id: z.uuid(),
@@ -1176,6 +1334,39 @@ export const candidates = app.group({ callers: ['human'] }, (r) => ({
1176
1334
  }),
1177
1335
  )
1178
1336
  ),
1337
+ files: r.query({
1338
+ input: ById,
1339
+ output: Files,
1340
+ access: { action: 'candidates.read', on: 'candidate', from: 'id' },
1341
+ })
1342
+ .handle(async ({ ctx }) => ({
1343
+ items: (await ctx.db.target.candidate_files.list()).items.map((row) => ({
1344
+ id: row.file.id,
1345
+ name: row.file.name,
1346
+ status: row.file.status,
1347
+ })),
1348
+ })),
1349
+ file: r.query({
1350
+ input: FileOf,
1351
+ output: file('attachments'),
1352
+ access: { action: 'candidates.read', on: 'candidate', from: 'id' },
1353
+ })
1354
+ .handle(async ({ input, ctx }) => {
1355
+ const rows = (await ctx.db.target.candidate_files.list()).items
1356
+ const row = rows.find((item) => item.file.id === input.file)
1357
+ if (row === undefined) throw new NotFoundError('no such file')
1358
+ return row.file
1359
+ }),
1360
+ photo: r.query({
1361
+ input: ById,
1362
+ output: file('photos'),
1363
+ access: { action: 'candidates.read', on: 'candidate', from: 'id' },
1364
+ })
1365
+ .handle(async ({ ctx }) => {
1366
+ const { photo } = await ctx.db.target.get()
1367
+ if (photo === null) throw new NotFoundError('the candidate sent no photo')
1368
+ return photo
1369
+ }),
1179
1370
  decide: r.mutation({
1180
1371
  input: Decide,
1181
1372
  output: Saved,
@@ -1264,6 +1455,9 @@ export const candidates = app.group({ callers: ['human'] }, (r) => ({
1264
1455
  }))
1265
1456
  ```
1266
1457
 
1458
+ - **HR's files.** `files`, `file` and `photo` serve the candidate's files to a holder of `candidates.read`. A file
1459
+ query is a `GET`, so the HR page links it for its signed-in session: `api.candidates.photo.url({ id })` in an
1460
+ `<img src>`, `api.candidates.file.url({ id, file })` in an `<a download>`.
1267
1461
  - **A code list an administrator edits is validated by reading it.** `decide` runs `on` one candidate with
1268
1462
  `candidates.review`, the read action of `reasons`, so `ctx.db.app.reasons` reads every reason there, read-only. A
1269
1463
  reason that is unknown or retired answers 400 that names it, before anything is written. Do not let a foreign key
@@ -1467,11 +1661,12 @@ export const settings = app.group({ callers: ['human'] }, (r) => ({
1467
1661
  testMail: r.mutation({
1468
1662
  output: Queued,
1469
1663
  access: { action: 'mail.test', app: true },
1470
- then: 'testMails',
1664
+ kicks: ['testMails'],
1471
1665
  })
1472
1666
  .handle(async ({ ctx }) => {
1473
1667
  const to = ctx.principal.email
1474
1668
  await ctx.db.app.test_mails.insert({ recipient: to })
1669
+ ctx.kick('testMails')
1475
1670
  return { queuedFor: to }
1476
1671
  }),
1477
1672
  }))
@@ -1490,7 +1685,6 @@ const stateAfter = (
1490
1685
 
1491
1686
  export const testMails = app.cron('* * * * *', {
1492
1687
  actions: ['mail.send'],
1493
- scope: 'app',
1494
1688
  retries: 0,
1495
1689
  sql: 'write',
1496
1690
  mailer: 'installation',
@@ -1571,7 +1765,7 @@ export const testMails = app.cron('* * * * *', {
1571
1765
  who holds `mail.test` mail any address past every consent the application asks for. Never take a recipient from the
1572
1766
  input for "send it to me".
1573
1767
  - **The endpoint sends nothing.** It has no `ctx.mail`: it records the e-mail in `test_mails` in its own transaction,
1574
- and `then: 'testMails'` kicks the job once that transaction committed. The person's answer does not wait for the
1768
+ and `kicks: ['testMails']` with `ctx.kick('testMails')` starts the job once that transaction committed. The person's answer does not wait for the
1575
1769
  run; the e-mail leaves seconds later. The schedule (`* * * * *`) sends what a kicked run left behind. The generated
1576
1770
  tests cover the endpoint like any `app` endpoint: a global holder of `mail.test` reaches it, a holder in one scope
1577
1771
  gets 403.
@@ -1600,21 +1794,22 @@ export const testMails = app.cron('* * * * *', {
1600
1794
  })` and calls `createClient(contract, 'portal', { fetch: kit.fetch, key: holder.key ?? '' })`.
1601
1795
  - The job asks IAM to issue and revoke links, and the kit's own IAM answers only the credentials of its callers, not a
1602
1796
  job's key, so `kit.runJob('intake')` needs `testKit(app, { membership })` with an `IamMembershipRpc` of your own that
1603
- answers `issueLink` and `revokeLink`. A test of a form passes `env: {
1604
- TURNSTILE_SECRET: '…', TURNSTILE_SITE_KEY: '…' }` and `challengeFetch`, a stand-in for Turnstile's answer, to `testKit`, and a fresh
1605
- `challengeToken` with each submission: a token is single use. A form that takes files is sent as the page sends it,
1606
- a `FormData` with the `input` part first, through `kit.fetch` with the page's `Origin` (`kit.origin`), and needs
1607
- `files: new MemoryFileObjects()` in `testKit`; `kit.runJob('intake')` then attaches the photo, and the scan job
1608
- (`FILE_SCAN_JOB`, with a `scanner`) makes it downloadable. Test the refusals beside the success: no or a failed
1609
- token, a filled honeypot, a file too large or of another type, a part the form does not declare, a page on another
1610
- origin; each keeps nothing. Test each stop of a delivery (a refused e-mail, IAM
1797
+ answers `issueLink` and `revokeLink`. A test of a public procedure with an ALTCHA challenge passes `env: {
1798
+ ALTCHA_HMAC_KEY: '<32 characters or more>' }` to `testKit` and a fresh `challengeToken: await kit.solveChallenge(api.visitors.register)`
1799
+ with each submission: a challenge passes once. The kit issues the cheapest challenge whatever the contract's
1800
+ `cost`, and verifies it as production does, so a solve takes milliseconds. A Turnstile challenge instead takes
1801
+ `challengeFetch`, a stand-in for Cloudflare's answer, and any fresh token. A submission with a photo goes through the typed
1802
+ client over `kit.fetch`, and needs `files: new MemoryFileObjects()` in `testKit`; `kit.runJob('intake')` then
1803
+ writes the photo's reference into the candidate, and the scan job (`FILE_SCAN_JOB`, with a `scanner`) makes it
1804
+ readable. Test the refusals beside the success: no or a failed token, a filled honeypot, a file too large or of
1805
+ another type, a page on another origin; each keeps nothing. Test each stop of a delivery (a refused e-mail, IAM
1611
1806
  down, a crash after each step, an answer lost after the provider took the e-mail): the next run must leave exactly
1612
1807
  one live, e-mailed link and the transport one message per key. Test a bad record beside good ones too: a
1613
1808
  registration the insert refuses and a recipient whose e-mail hangs end dead after `MAX_ATTEMPTS` runs, a recipient
1614
1809
  the transport refuses ends dead at once, and every other record is delivered and every run succeeds; a
1615
1810
  registration another session locks waits. A failed delivery waits for its `next_attempt_at`: a test that runs the
1616
1811
  job again moves that column into the past first, as an operator's repair would.
1617
- - A test that submits a form, or calls a mutation that declares `then`, kicks the job too, and the kicked run may start
1812
+ - A test that calls a mutation that kicks (`kicks`, `ctx.kick`) starts the job too, and the kicked run may start
1618
1813
  at once: arm a fault before the call, never between it and the run. `kit.drain()` waits for the kicked runs, and
1619
1814
  `kit.runJob('intake')` first waits for them, then runs once.
1620
1815
  - The mail transport is the kit's stand-in: `kit.mail`, a `MemoryEmailSender` that takes every message a job sends
@@ -1674,7 +1869,7 @@ if (postgres !== null) {
1674
1869
  database: postgres.database,
1675
1870
  files: new MemoryFileObjects(),
1676
1871
  membership,
1677
- env: { TURNSTILE_SECRET: 'test', TURNSTILE_SITE_KEY: 'test-site-key' },
1872
+ env: { ALTCHA_HMAC_KEY: 'test-altcha-hmac-key-of-32-characters' },
1678
1873
  })
1679
1874
  afterEach(() => kit.mail.heal())
1680
1875
  const sentTo = (address: string) =>
@@ -1705,7 +1900,7 @@ if (postgres !== null) {
1705
1900
  database: postgres.database,
1706
1901
  files: new MemoryFileObjects(),
1707
1902
  membership,
1708
- env: { TURNSTILE_SECRET: 'test', TURNSTILE_SITE_KEY: 'test-site-key' },
1903
+ env: { ALTCHA_HMAC_KEY: 'test-altcha-hmac-key-of-32-characters' },
1709
1904
  mail: 'none',
1710
1905
  },
1711
1906
  )