@fabrika/app 0.5.5 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/package.json +9 -9
  2. package/skill/SKILL.md +29 -21
  3. package/skill/references/app-setup.md +54 -36
  4. package/skill/references/dev.md +5 -5
  5. package/skill/references/endpoint.md +152 -25
  6. package/skill/references/errors.md +26 -23
  7. package/skill/references/files.md +187 -137
  8. package/skill/references/jobs.md +82 -35
  9. package/skill/references/public-intake.md +387 -236
  10. package/skill/references/spa.md +5 -5
  11. package/skill/references/start.md +2 -1
  12. package/skill/references/table-and-migration.md +24 -2
  13. package/skill/references/tests.md +8 -5
  14. package/skill/references/webhooks-links-connectors.md +65 -12
  15. package/src/bun.ts +3 -2
  16. package/src/client/contract.ts +184 -74
  17. package/src/client/index.ts +2 -1
  18. package/src/client/transport.ts +7 -2
  19. package/src/cloudflare.ts +2 -0
  20. package/src/contract/artifact-schema.ts +110 -15
  21. package/src/contract/build.ts +1184 -613
  22. package/src/contract/compile.ts +44 -10
  23. package/src/contract/connect.ts +71 -5
  24. package/src/contract/context.ts +132 -23
  25. package/src/contract/declaration.ts +78 -63
  26. package/src/contract/defaults.ts +64 -58
  27. package/src/contract/define.ts +76 -124
  28. package/src/contract/file.ts +78 -0
  29. package/src/contract/group.ts +244 -37
  30. package/src/contract/index.ts +18 -0
  31. package/src/contract/kit.ts +206 -126
  32. package/src/contract/platform.ts +8 -9
  33. package/src/contract/redirect.ts +49 -0
  34. package/src/contract/schema-from-shape.ts +7 -0
  35. package/src/contract/schema-shape.ts +10 -0
  36. package/src/contract/snapshot-source.ts +5 -0
  37. package/src/contract/token.ts +89 -0
  38. package/src/contract/webhook-binding.ts +26 -0
  39. package/src/contract/webhook-scheme.ts +32 -0
  40. package/src/files/handle.ts +22 -0
  41. package/src/files/kinds.ts +454 -0
  42. package/src/files/scan.ts +15 -9
  43. package/src/files/streams.ts +111 -0
  44. package/src/honeypot.ts +7 -0
  45. package/src/implement/anonymous.ts +18 -42
  46. package/src/implement/boot.ts +177 -159
  47. package/src/implement/bounded.ts +23 -0
  48. package/src/implement/browser-config.ts +4 -4
  49. package/src/implement/capability.ts +78 -2
  50. package/src/implement/{form-admission.ts → client-address.ts} +3 -38
  51. package/src/implement/dispatch.ts +810 -409
  52. package/src/implement/egress.ts +84 -0
  53. package/src/implement/faults.ts +11 -1
  54. package/src/implement/index.ts +3 -2
  55. package/src/implement/invocation.ts +135 -35
  56. package/src/implement/params.ts +88 -0
  57. package/src/implement/principal.ts +7 -0
  58. package/src/implement/public.ts +142 -0
  59. package/src/implement/tokens.ts +219 -0
  60. package/src/implement/types.ts +24 -3
  61. package/src/implement/urls.ts +78 -0
  62. package/src/implement/verifiers.ts +299 -0
  63. package/src/implement/webhook.ts +136 -55
  64. package/src/implement/workload.ts +16 -14
  65. package/src/index.ts +3 -0
  66. package/src/jobs/mail.ts +98 -10
  67. package/src/jobs/runner.ts +103 -71
  68. package/src/jobs/scrub.ts +4 -1
  69. package/src/jobs/types.ts +19 -2
  70. package/src/react/hooks.ts +2 -2
  71. package/src/react/provider.tsx +2 -2
  72. package/src/testing/dev.ts +2 -2
  73. package/src/testing/endpoint-access.ts +167 -128
  74. package/src/testing/kit.ts +79 -24
  75. package/src/testing/policy-isolation.ts +55 -29
  76. package/src/testing/proxy.ts +6 -2
  77. package/src/testing/sample-file.ts +30 -0
  78. package/src/client/files.ts +0 -135
  79. package/src/contract/file-schemas.ts +0 -25
  80. package/src/files/collections.ts +0 -848
@@ -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,143 @@ 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: {
425
+ provider: 'turnstile',
426
+ secret: 'TURNSTILE_SECRET',
427
+ siteKey: 'TURNSTILE_SITE_KEY',
428
+ },
429
+ rate: { perClient: 100, perEndpoint: 100, bytesPerHour: 536870912 },
430
+ },
431
+ kicks: ['intake'],
432
+ })
433
+ .handle(async ({ input, ctx }) => {
434
+ if (honeypot(input.website)) return {}
435
+ await ctx.db.app('candidates.register').registrations.insert({
436
+ email: input.email,
437
+ full_name: input.fullName,
438
+ photo: input.photo ?? null,
439
+ })
440
+ ctx.kick('intake')
441
+ return {}
442
+ }),
443
+ resend: r.mutation({
444
+ input: Resend,
445
+ output: Empty,
446
+ access: { public: true },
447
+ actions: ['candidates.register'],
448
+ limits: { maxBytes: 1024 },
449
+ kicks: ['intake'],
450
+ })
451
+ .handle(async ({ input, ctx }) => {
452
+ await ctx.db.app('candidates.register').resend_requests.insert({
453
+ email: input.email,
454
+ })
455
+ ctx.kick('intake')
456
+ return {}
457
+ }),
458
+ }))
459
+ ```
460
+
430
461
  - **The public page.** `access: { public: true }` serves the SPA to anyone, without a token, and takes no other key.
431
462
  At `/` it answers every `GET` that no more specific endpoint serves, so the HR SPA (`/hr`) and the link's SPA (`/p`)
432
463
  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.
464
+ read: the page calls only the public procedures.
465
+ - **A schema with a file field** stays in the slice's `endpoints.ts`, as `Register` does here. `src/schemas.ts` is
466
+ loaded by the SPA and by `queries.ts`, and both are refused `@fabrika/app/contract`, where `file` comes from
467
+ (`spa-server-import`, `layer-import`).
468
+ - **A public procedure** sits in a group with no callers (`app.group({}, …)`), declares `access: { public: true }`
469
+ and the `actions` it holds, and is served at `POST /api/visitors.register`. `access` says anyone may call it;
470
+ `actions` say what its handler may do. It runs as its own workload, with those actions only, whoever calls: the
471
+ approver reads "anyone may call `api.visitors.register`; it runs with `candidates.register`".
472
+ - **What its handler reaches.** `ctx.db.app('<action>')` of an application table: an insert reads back nothing (the
473
+ id is generated before the insert), a read needs the table's `read` action, and an update or a remove takes only a
474
+ row id a verified signed token of the request carries (`references/endpoint.md`). It never locks or claims a row. It
475
+ declares only `kicks` and `signs`: no `sql`, `unscoped`, `issues`, `connect`, `egress`, `binds` or mail. No role may
476
+ grant its action, so a role change cannot widen it; a table staff read too lists both actions.
477
+ - **Constant work.** Answer and work the same for a new and a known visitor (`{}` here, one inserted row): an answer
478
+ or a delay that depends on what exists tells a stranger who registered. The job decides later.
479
+ - **`ctx.client`** is `{ ip, userAgent }` as the proxy saw them, for consent evidence in a column of the row; `ip` is
480
+ `null` on Zerops. Never decide on either.
481
+ - **Its abuse control.** `honeypot(input.website)` is true when the field the page hides is filled: answer the same
482
+ and write no row. `admission.challenge` makes the framework verify the Turnstile token in the input field
483
+ `challengeToken` before the handler runs (a failed or replayed token 400 `challenge_failed`, an unreachable provider 503, no row either way) and adds
484
+ Turnstile's origin to the CSP. `TURNSTILE_SECRET` is an application variable the human sets; without it the
485
+ application does not boot. `siteKey` names the variable with the widget's public site key, which every SPA mount
486
+ serves its pages; without it the application does not boot either. Another public value a page needs per
487
+ environment (a link target) goes in the SPA mount's `browserConfig` list: `c.spa('/', { …, browserConfig: ['SIGNUP_URL'] })`. Both
488
+ reach the page in `<prefix>/__fabrika-config.json` as `public: { <VARIABLE>: <value> }`, and the approver sees each
489
+ name. Never name a secret there: the contract refuses a variable that is both.
490
+ `admission.rate: { perClient, perEndpoint }` (per minute, 10 and 100 by default for a public mutation) answers 429
491
+ past either limit; `null` turns one off. A public query counts nothing unless it declares a bucket, since each
492
+ bucket is a database write per call: a page of twenty images costs no write. All public procedures of an instance
493
+ hold at most a quarter of its database connections together; past that the answer is 503, and staff keep the rest.
494
+ `limits: { maxBytes, timeoutMs }` bounds the body (64 KiB by default, plus each file field's files) and the handler
495
+ (10 s). With file fields the JSON `input` part takes up to that limit, and the whole body, files included, stays
496
+ within it.
497
+ - **The order of a public request**, each refusal with its answer: the origin rule (403), the in-flight share (503),
498
+ every bucket (429), the bounded body and the strict input with its signed tokens (413, 400), the workload token (503
499
+ while the platform's identity service is down), the challenge (400, 503 when the provider is unreachable), the staged file parts, then the handler, the
500
+ commit and the kicks.
501
+ - **A form on someone else's page.** A page served from this application's own origin needs nothing. A partner's page
502
+ or a separate marketing site needs `admission.origins`: `'listed'` for the origins an operator lists for the
503
+ environment in Control, `'any'` for any site (an embeddable widget). Keep the honeypot, the challenge and the rate
504
+ limit: `'any'` invites every site. Such a page is not yours to change in step with the application, so give it a URL
505
+ and a widget action that do not follow your group names: `http: { path: '/register' }` and
506
+ `challenge: { …, action: 'register' }`. The path lies outside every mount's prefix (the root SPA's excepted).
448
507
  - **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
508
+ every visitor counts as one client there, so `perClient` acts as a second per-endpoint limit: set it to the expected
509
+ peak, never lower it to stop a flood. The challenge is the defense that does not depend on the address.
510
+ - **Counting clicks** on a public page has no primitive. The pattern is a public procedure without a challenge that
511
+ inserts one row per click, posted with `fetch(…, { keepalive: true })`. Know both limits before you offer it:
512
+ anyone can inflate the counts (the rate limits bound only how fast), and nothing reads the rows but a job you
454
513
  write. Ask the human first; usually the application should not count clicks.
455
514
  - **Other origins** the pages load (an image host, an API the browser calls) go in the contract's `csp` section:
456
515
  `csp: { scriptSrc: [{ origin: 'https://…', purpose: '…' }] }`, beside `connectSrc`, `imgSrc`, `frameSrc`,
457
516
  `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
517
+ - **Scanned files.** A visitor's upload waits `quarantined` until ClamAV answers clean (`references/files.md`): a file
518
+ answer is 409 until then. On Zerops the deploy service needs `verticalAutoscaling: { minRam: 2 }`.
519
+ - **A photo with the registration.** `photo: z.optional(file('photos'))` lets the visitor send one file beside the
520
+ input; the client then sends the body as multipart. A public file field needs a challenge, `bytesPerHour` and a kind
521
+ with `scan: true`, whose `types` and `maxBytes` bound the file. The handler writes the reference into
522
+ `registrations.photo`, and the job writes the same reference into `candidates.photo`: the file is never copied. A
523
+ file no column references any more (a known address, a registration an operator deleted) is deleted by the
524
+ framework's sweep. A file the kind refuses (a type it does not admit, bytes that are not that type, too large) is
525
+ 415 or 413 after the challenge was spent, so the page checks the type and the size before it submits, and solves
526
+ the challenge again after a refusal. The procedure's bodies take at most `bytesPerHour` in all (512 MiB here).
527
+ Each request is charged its declared `Content-Length` before its body is read, which a browser sends for a form,
528
+ or the whole body limit when it declares none, so with a 15 MiB photo about 34 maximal requests fit in an hour;
529
+ past it the procedure answers 429 until the hour ends. Size the budget for the expected peak: a flood that spends
530
+ it costs that hour's photos, not the storage.
531
+ - **The kick.** `kicks: ['intake']` and `ctx.kick('intake')` start a run of the job within seconds, once the row
532
+ committed, under the job's own key. The visitor's answer never waits for the run. Kicks coalesce on the job's lease
533
+ (at most one run and one pending, however many visitors submit), so a flood adds no work: the run still claims in
534
+ batches of 50, within each address's budget. A kick carries nothing; the job reads its tables.
535
+
536
+ ## The job: registration → record → link → e-mail
483
537
 
484
538
  ```ts src/links.ts
485
539
  import { defineDeps } from '@fabrika/app/implement'
@@ -553,15 +607,8 @@ import { z } from 'zod/v4'
553
607
  import { app } from '../app-kit'
554
608
  import { links } from '../links'
555
609
 
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
610
  const Ask = z.strictObject({ email: z.string() })
563
611
  const Id = z.strictObject({ id: z.string() })
564
- const Attempts = z.strictObject({ attempts: z.number() })
565
612
  const State = z.strictObject({ linkState: z.string() })
566
613
  const Due = z.strictObject({
567
614
  id: z.string(),
@@ -593,7 +640,6 @@ export const intake = app.cron('* * * * *', {
593
640
  'candidates.verify',
594
641
  'candidates.attach',
595
642
  ],
596
- scope: 'app',
597
643
  retries: 0,
598
644
  sql: 'write',
599
645
  issues: 'portal',
@@ -616,47 +662,42 @@ export const intake = app.cron('* * * * *', {
616
662
  await failed(error)
617
663
  }
618
664
  }
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) {
665
+ // A registration becomes a candidate once; the procedure told the visitor nothing either way.
666
+ const claimed = await ctx.db.app('candidates.process').registrations.claim({
667
+ limit: BATCH,
668
+ })
669
+ for (const row of claimed) {
626
670
  await eachRecord(() =>
627
671
  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
672
  const [created] = await tx.sql(Id)`
637
673
  insert into candidates (id, email, name, created_by, version)
638
- values (${crypto.randomUUID()}, ${row.email}, ${row.fullName},
674
+ values (${crypto.randomUUID()}, ${row.email}, ${row.full_name},
639
675
  ${ctx.principal.id}, 1)
640
676
  on conflict (email) do nothing
641
677
  returning id`
642
- // The photo becomes the new candidate's in this transaction; a known address gets none.
678
+ // The photo's reference moves to the new candidate in this transaction; a known address gets none.
643
679
  if (created !== undefined && row.photo !== null) {
644
680
  const candidate = await tx.ref(
645
681
  'candidates.attach',
646
682
  'candidate',
647
683
  created.id,
648
684
  )
649
- await tx.files('photos').attach(candidate, row.photo)
685
+ await tx.db.on(candidate).update({ photo: row.photo }, {
686
+ expectVersion: 1,
687
+ })
650
688
  }
689
+ await tx.db.on(row.ref).remove({ expectVersion: row.version })
651
690
  }), 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`
691
+ // The claim counted the attempt and set the backoff; the row keeps the step that failed.
692
+ await ctx.transaction((tx) =>
693
+ tx.db.on(row.ref).update({ last_failure: 'register' }, {
694
+ expectVersion: row.version,
695
+ })
657
696
  )
658
- if (counted !== undefined && counted.attempts >= MAX_ATTEMPTS) {
659
- console.error(`intake: registration ${id} is dead; an operator decides`)
697
+ if (row.attempts >= MAX_ATTEMPTS) {
698
+ console.error(
699
+ `intake: registration ${row.id} is dead; an operator decides`,
700
+ )
660
701
  }
661
702
  })
662
703
  }
@@ -815,7 +856,7 @@ The job keeps eight invariants. Keep them when you change it:
815
856
  deliveries; the next run takes the rest. Each record's work runs in `eachRecord`: a failure (a row the insert refuses,
816
857
  a refused recipient, an e-mail past `SEND_TIMEOUT_MS`, IAM down) adds one to the record's `attempts`, keeps the step
817
858
  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
859
+ dead: a registration stays in its table, no longer due, a delivery is `failed`; the job logs each once, by
819
860
  id, never by address. An operator decides: fix the row, or delete it. A resend revives a `failed` delivery, within
820
861
  the budget. A failure of an ended attempt (`ctx.signal` aborted) is the attempt's, not the record's: it is thrown,
821
862
  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 +871,9 @@ The job keeps eight invariants. Keep them when you change it:
830
871
  lost answer) sends it again only when `ctx.mail.capabilities.idempotent` holds, and otherwise leaves it dead for a
831
872
  person, because the e-mail may have arrived. A failure the transport says did not send it (`outcome: 'not_sent'`)
832
873
  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
874
+ - **A record is claimed where it is consumed.** `claim` leases each registration in a short transaction of its own and
875
+ skips a row other work holds; the job then removes it and inserts the candidate in one transaction, under the
876
+ 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
877
  takes it, and the reservation skips a candidate row other work holds. A row someone else locks (an operator's
836
878
  session, an HR request) waits for a later run and blocks nothing behind it.
837
879
  - **The budget is charged first.** Each delivery reserves one e-mail of the address's daily budget, in its own
@@ -861,10 +903,11 @@ More on the job:
861
903
  links it issued. Its `actions` must hold the mount's `issuedBy` and every grant of the mount, or the contract is
862
904
  refused: IAM lets an issuer delegate only what it holds. The link is bound to one candidate (the `Ref`), and the
863
905
  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,
906
+ - **`sql: 'write'`** is the declared reach for the candidate's insert (`on conflict (email) do nothing`), the resend
907
+ requests and the delivery state. A file column is written only through `tx.db`, so the photo's reference goes into
908
+ `candidates.photo` through `tx.db.on(ref).update`. **A job writes no scope fragment**: it is application-wide, so
909
+ `tx.sql.scope` of its `Within` is every row, and an application table has no scope at all.
910
+ - **The latency.** The procedures' kick starts a run within seconds of a submission, so the e-mail leaves seconds later,
868
911
  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
912
  e-mail. Keep the schedule (`* * * * *`): it is the recovery path. A kicked run that dies (a crash, a deploy, a
870
913
  timeout) leaves its rows, and the next scheduled run finishes them, within a minute. `retries: 0`: that run is the
@@ -884,9 +927,26 @@ More on the job:
884
927
  ## The visitor's link: verify once, attach files
885
928
 
886
929
  ```ts src/portal/endpoints.ts
930
+ import { ConflictError, NotFoundError } from '@fabrika/app'
931
+ import { file } from '@fabrika/app/contract'
932
+ import { z } from 'zod/v4'
887
933
  import { app } from '../app-kit'
888
934
  import { Me, Verified } from '../schemas'
889
935
 
936
+ const Attach = z.strictObject({ file: file('attachments') })
937
+ const Attached = z.strictObject({ id: z.uuid() })
938
+ const Document = z.strictObject({ id: z.uuid() })
939
+ const Documents = z.strictObject({
940
+ items: z.array(z.strictObject({
941
+ id: z.uuid(),
942
+ name: z.nullable(z.string()),
943
+ status: z.string(),
944
+ })),
945
+ })
946
+
947
+ /** The files one candidate keeps. */
948
+ const MAX_FILES = 5
949
+
890
950
  export const portal = app.link('/p', {
891
951
  spa: 'dist/portal',
892
952
  dimension: 'candidate',
@@ -918,6 +978,43 @@ export const portal = app.link('/p', {
918
978
  })
919
979
  return { verifiedAt: now.toISOString() }
920
980
  }),
981
+ attach: l.mutation({
982
+ input: Attach,
983
+ output: Attached,
984
+ access: { action: 'candidates.attach' },
985
+ })
986
+ .handle(async ({ input, ctx }) => {
987
+ // The lock serializes two uploads of one candidate, so the count holds.
988
+ await ctx.db.target.getForUpdate()
989
+ const held = await ctx.db.target.candidate_files.list({ limit: MAX_FILES })
990
+ if (held.items.length >= MAX_FILES) {
991
+ throw new ConflictError(`a candidate keeps at most ${MAX_FILES} files`)
992
+ }
993
+ await ctx.db.target.candidate_files.insert({ file: input.file })
994
+ return { id: input.file.id }
995
+ }),
996
+ documents: l.query({
997
+ output: Documents,
998
+ access: { action: 'candidates.read' },
999
+ })
1000
+ .handle(async ({ ctx }) => ({
1001
+ items: (await ctx.db.target.candidate_files.list()).items.map((row) => ({
1002
+ id: row.file.id,
1003
+ name: row.file.name,
1004
+ status: row.file.status,
1005
+ })),
1006
+ })),
1007
+ document: l.query({
1008
+ input: Document,
1009
+ output: file('attachments'),
1010
+ access: { action: 'candidates.read' },
1011
+ })
1012
+ .handle(async ({ input, ctx }) => {
1013
+ const rows = (await ctx.db.target.candidate_files.list()).items
1014
+ const row = rows.find((item) => item.file.id === input.id)
1015
+ if (row === undefined) throw new NotFoundError('no such document')
1016
+ return row.file
1017
+ }),
921
1018
  }),
922
1019
  })
923
1020
  ```
@@ -926,11 +1023,12 @@ export const portal = app.link('/p', {
926
1023
  answers the same time. IAM counts no uses: the proxy honors a token it holds without asking IAM.
927
1024
  - A link procedure acts on the link's own object: no `from`, no id in the input. A key of another candidate reaches
928
1025
  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.
1026
+ - A file is a value in `candidate_files.file`: `attach` writes the uploaded reference with `candidates.attach`,
1027
+ `documents` lists the rows with each file's `name` and `status`, and `document` answers one file
1028
+ (`output: file('attachments')`), all for the link's candidate only: another candidate's file id answers 404. The
1029
+ holder's client calls `portal.attach({ file })` and `download(portal.document, { id })`; a key never goes into a
1030
+ URL. HR reaches the same rows through procedures of its own with `candidates.read`. A link mount reaches only the
1031
+ files its own procedures answer.
934
1032
 
935
1033
  ```ts web/portal/main.ts
936
1034
  import { createClient, RpcError, shareKey } from '@fabrika/app/client'
@@ -958,7 +1056,7 @@ export const confirm = async (status: HTMLElement): Promise<void> => {
958
1056
  export const attach = async (file: File): Promise<string> => {
959
1057
  if (portal === null) return 'This link holds no key.'
960
1058
  try {
961
- await portal.files.attachments.upload(file)
1059
+ await portal.attach({ file })
962
1060
  return 'Received: it is being checked.'
963
1061
  } catch (error) {
964
1062
  return error instanceof RpcError ? `Refused: ${error.type}` : 'Failed'
@@ -983,13 +1081,16 @@ export const siteKey = async (): Promise<string | null> => {
983
1081
  }
984
1082
  ```
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
+ The public page calls the public procedure through the typed client, like any other. An input with a file is sent
1085
+ as a multipart body, and one without a file as JSON.
990
1086
 
991
1087
  ```ts web/site/register.ts
992
- /** What the `photos` collection admits; the page checks it before it spends the challenge. */
1088
+ import { createClient } from '@fabrika/app/client'
1089
+ import { contract } from '../../fabrika.contract'
1090
+
1091
+ const api = createClient(contract, 'api')
1092
+
1093
+ /** What the `photos` kind admits; the page checks it before it spends the challenge. */
993
1094
  const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp']
994
1095
  const PHOTO_MAX_BYTES = 15728640
995
1096
 
@@ -1002,32 +1103,33 @@ export const register = async (
1002
1103
  const value = data.get(name)
1003
1104
  return typeof value === 'string' ? value : ''
1004
1105
  }
1005
- const body = new FormData()
1006
- body.append(
1007
- 'input',
1008
- JSON.stringify({
1106
+ // <input type="file" name="photo" accept="image/*" capture="environment">; an empty one sends nothing.
1107
+ const sent = data.get('photo')
1108
+ const photo = sent instanceof File && sent.size > 0 ? sent : undefined
1109
+ if (
1110
+ photo !== undefined
1111
+ && (!PHOTO_TYPES.includes(photo.type) || photo.size > PHOTO_MAX_BYTES)
1112
+ ) {
1113
+ return false
1114
+ }
1115
+ try {
1116
+ await api.visitors.register({
1009
1117
  email: field('email'),
1010
- full_name: field('full_name'),
1118
+ fullName: field('full_name'),
1011
1119
  website: field('website'),
1012
1120
  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)
1121
+ photo,
1122
+ })
1123
+ return true
1124
+ } catch {
1125
+ return false
1022
1126
  }
1023
- const response = await fetch('/register', { method: 'POST', body })
1024
- return response.ok
1025
1127
  }
1026
1128
  ```
1027
1129
 
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
1130
+ - The page renders the Turnstile widget with the site key `siteKey()` reads and `action` set to the procedure's
1131
+ endpoint id with `_` for `.` (`api_visitors_register`), and passes the widget's token. Say "check your inbox" on success whatever the address was.
1132
+ - The resend form calls `api.visitors.resend({ email })`. It answers the same for an unknown, a verified and a
1031
1133
  rate-limited address; the job decides.
1032
1134
  - Build each SPA into its `dir` (`dist/site`, `dist/portal`, `dist/hr`): one `Bun.build` per `index.html` in
1033
1135
  `build-web.ts`.
@@ -1059,8 +1161,14 @@ export const stageOf = (
1059
1161
  ```
1060
1162
 
1061
1163
  ```ts src/candidates/endpoints.ts
1062
- import { BadRequestError, ConflictError, fromPage, fromRow } from '@fabrika/app'
1063
- import { attachment } from '@fabrika/app/contract'
1164
+ import {
1165
+ BadRequestError,
1166
+ ConflictError,
1167
+ fromPage,
1168
+ fromRow,
1169
+ NotFoundError,
1170
+ } from '@fabrika/app'
1171
+ import { attachment, file } from '@fabrika/app/contract'
1064
1172
  import { z } from 'zod/v4'
1065
1173
  import { app } from '../app-kit'
1066
1174
  import { CandidateStatus } from '../schemas'
@@ -1094,6 +1202,14 @@ const ListCandidates = z.strictObject({
1094
1202
  after: Cursor.optional(),
1095
1203
  })
1096
1204
  const ById = z.strictObject({ id: z.uuid() })
1205
+ const FileOf = z.strictObject({ id: z.uuid(), file: z.uuid() })
1206
+ const Files = z.strictObject({
1207
+ items: z.array(z.strictObject({
1208
+ id: z.uuid(),
1209
+ name: z.nullable(z.string()),
1210
+ status: z.string(),
1211
+ })),
1212
+ })
1097
1213
  const HistoryOf = z.strictObject({ id: z.uuid(), after: Cursor.optional() })
1098
1214
  const Decide = z.strictObject({
1099
1215
  id: z.uuid(),
@@ -1176,6 +1292,39 @@ export const candidates = app.group({ callers: ['human'] }, (r) => ({
1176
1292
  }),
1177
1293
  )
1178
1294
  ),
1295
+ files: r.query({
1296
+ input: ById,
1297
+ output: Files,
1298
+ access: { action: 'candidates.read', on: 'candidate', from: 'id' },
1299
+ })
1300
+ .handle(async ({ ctx }) => ({
1301
+ items: (await ctx.db.target.candidate_files.list()).items.map((row) => ({
1302
+ id: row.file.id,
1303
+ name: row.file.name,
1304
+ status: row.file.status,
1305
+ })),
1306
+ })),
1307
+ file: r.query({
1308
+ input: FileOf,
1309
+ output: file('attachments'),
1310
+ access: { action: 'candidates.read', on: 'candidate', from: 'id' },
1311
+ })
1312
+ .handle(async ({ input, ctx }) => {
1313
+ const rows = (await ctx.db.target.candidate_files.list()).items
1314
+ const row = rows.find((item) => item.file.id === input.file)
1315
+ if (row === undefined) throw new NotFoundError('no such file')
1316
+ return row.file
1317
+ }),
1318
+ photo: r.query({
1319
+ input: ById,
1320
+ output: file('photos'),
1321
+ access: { action: 'candidates.read', on: 'candidate', from: 'id' },
1322
+ })
1323
+ .handle(async ({ ctx }) => {
1324
+ const { photo } = await ctx.db.target.get()
1325
+ if (photo === null) throw new NotFoundError('the candidate sent no photo')
1326
+ return photo
1327
+ }),
1179
1328
  decide: r.mutation({
1180
1329
  input: Decide,
1181
1330
  output: Saved,
@@ -1264,6 +1413,9 @@ export const candidates = app.group({ callers: ['human'] }, (r) => ({
1264
1413
  }))
1265
1414
  ```
1266
1415
 
1416
+ - **HR's files.** `files`, `file` and `photo` serve the candidate's files to a holder of `candidates.read`. A file
1417
+ query is a `GET`, so the HR page links it for its signed-in session: `api.candidates.photo.url({ id })` in an
1418
+ `<img src>`, `api.candidates.file.url({ id, file })` in an `<a download>`.
1267
1419
  - **A code list an administrator edits is validated by reading it.** `decide` runs `on` one candidate with
1268
1420
  `candidates.review`, the read action of `reasons`, so `ctx.db.app.reasons` reads every reason there, read-only. A
1269
1421
  reason that is unknown or retired answers 400 that names it, before anything is written. Do not let a foreign key
@@ -1467,11 +1619,12 @@ export const settings = app.group({ callers: ['human'] }, (r) => ({
1467
1619
  testMail: r.mutation({
1468
1620
  output: Queued,
1469
1621
  access: { action: 'mail.test', app: true },
1470
- then: 'testMails',
1622
+ kicks: ['testMails'],
1471
1623
  })
1472
1624
  .handle(async ({ ctx }) => {
1473
1625
  const to = ctx.principal.email
1474
1626
  await ctx.db.app.test_mails.insert({ recipient: to })
1627
+ ctx.kick('testMails')
1475
1628
  return { queuedFor: to }
1476
1629
  }),
1477
1630
  }))
@@ -1490,7 +1643,6 @@ const stateAfter = (
1490
1643
 
1491
1644
  export const testMails = app.cron('* * * * *', {
1492
1645
  actions: ['mail.send'],
1493
- scope: 'app',
1494
1646
  retries: 0,
1495
1647
  sql: 'write',
1496
1648
  mailer: 'installation',
@@ -1571,7 +1723,7 @@ export const testMails = app.cron('* * * * *', {
1571
1723
  who holds `mail.test` mail any address past every consent the application asks for. Never take a recipient from the
1572
1724
  input for "send it to me".
1573
1725
  - **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
1726
+ and `kicks: ['testMails']` with `ctx.kick('testMails')` starts the job once that transaction committed. The person's answer does not wait for the
1575
1727
  run; the e-mail leaves seconds later. The schedule (`* * * * *`) sends what a kicked run left behind. The generated
1576
1728
  tests cover the endpoint like any `app` endpoint: a global holder of `mail.test` reaches it, a holder in one scope
1577
1729
  gets 403.
@@ -1600,21 +1752,20 @@ export const testMails = app.cron('* * * * *', {
1600
1752
  })` and calls `createClient(contract, 'portal', { fetch: kit.fetch, key: holder.key ?? '' })`.
1601
1753
  - 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
1754
  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: {
1755
+ answers `issueLink` and `revokeLink`. A test of a public procedure with a challenge passes `env: {
1604
1756
  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
1757
+ `challengeToken` with each submission: a token is single use. A submission with a photo goes through the typed
1758
+ client over `kit.fetch`, and needs `files: new MemoryFileObjects()` in `testKit`; `kit.runJob('intake')` then
1759
+ writes the photo's reference into the candidate, and the scan job (`FILE_SCAN_JOB`, with a `scanner`) makes it
1760
+ readable. Test the refusals beside the success: no or a failed token, a filled honeypot, a file too large or of
1761
+ another type, a page on another origin; each keeps nothing. Test each stop of a delivery (a refused e-mail, IAM
1611
1762
  down, a crash after each step, an answer lost after the provider took the e-mail): the next run must leave exactly
1612
1763
  one live, e-mailed link and the transport one message per key. Test a bad record beside good ones too: a
1613
1764
  registration the insert refuses and a recipient whose e-mail hangs end dead after `MAX_ATTEMPTS` runs, a recipient
1614
1765
  the transport refuses ends dead at once, and every other record is delivered and every run succeeds; a
1615
1766
  registration another session locks waits. A failed delivery waits for its `next_attempt_at`: a test that runs the
1616
1767
  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
1768
+ - A test that calls a mutation that kicks (`kicks`, `ctx.kick`) starts the job too, and the kicked run may start
1618
1769
  at once: arm a fault before the call, never between it and the run. `kit.drain()` waits for the kicked runs, and
1619
1770
  `kit.runJob('intake')` first waits for them, then runs once.
1620
1771
  - The mail transport is the kit's stand-in: `kit.mail`, a `MemoryEmailSender` that takes every message a job sends