@fabrika/app 0.5.4 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabrika/app",
3
- "version": "0.5.4",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -93,14 +93,14 @@
93
93
  "test": "bun test --path-ignore-patterns='src/react/**' && bun test --isolate src/react"
94
94
  },
95
95
  "dependencies": {
96
- "@fabrika/auth": "0.5.4",
97
- "@fabrika/auth-core": "0.5.4",
98
- "@fabrika/db": "0.5.4",
99
- "@fabrika/email": "0.5.4",
100
- "@fabrika/jwks": "0.5.4",
101
- "@fabrika/platform": "0.5.4",
102
- "@fabrika/platform-node": "0.5.4",
103
- "@fabrika/proxy-core": "0.5.4",
96
+ "@fabrika/auth": "0.6.0",
97
+ "@fabrika/auth-core": "0.6.0",
98
+ "@fabrika/db": "0.6.0",
99
+ "@fabrika/email": "0.6.0",
100
+ "@fabrika/jwks": "0.6.0",
101
+ "@fabrika/platform": "0.6.0",
102
+ "@fabrika/platform-node": "0.6.0",
103
+ "@fabrika/proxy-core": "0.6.0",
104
104
  "jose": "^5.9.0"
105
105
  },
106
106
  "peerDependencies": {
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: fabrika-app
3
- version: 0.5.4
3
+ version: 0.6.0
4
4
  description: >
5
5
  How to write and change a Fabrika application: src/vocabulary.ts, db/migrations/, src/db/tables.ts, the
6
6
  slices' endpoints.ts with their handlers, fabrika.contract.ts, jobs, files, webhooks, share links, connectors
@@ -220,14 +220,15 @@ the access check. `references/spa.md` shows the stack.
220
220
 
221
221
  ## Off the road: declare it
222
222
 
223
- | What the handler needs | Declare on the endpoint | It gets |
224
- | ------------------------------ | ------------------------------- | -------------------------------------------------- |
225
- | a join, a report, a bulk write | `sql: 'read'` (or `'write'`) | `` ctx.sql(Row)`…` ``, scoped with `ctx.sql.scope` |
226
- | rows outside its capability | `unscoped: { <table>: 'read' }` | `ctx.db.unscoped.<table>` |
227
- | another application's export | `connect: ['<import>']` | `ctx.connect.<import>` |
228
- | a share link | `issues: '<link mount>'` | `ctx.links.issue` |
229
- | e-mail (a cron job only) | `mailer: 'installation'` | `ctx.mail.send` |
230
- | a trigger, function or view | its migration | — |
223
+ | What the handler needs | Declare on the endpoint | It gets |
224
+ | ------------------------------ | ---------------------------------------------- | --------------------------------------------------- |
225
+ | a join, a report, a bulk write | `sql: 'read'` (or `'write'`) | `` ctx.sql(Row)`…` ``, scoped with `ctx.sql.scope` |
226
+ | rows outside its capability | `unscoped: { <table>: 'read' }` | `ctx.db.unscoped.<table>` |
227
+ | another application's export | `connect: ['<import>']` | `ctx.connect.<import>` |
228
+ | a share link | `issues: '<link mount>'` | `ctx.links.issue` |
229
+ | e-mail (a cron job only) | `mailer: 'installation'` | `ctx.mail.send` |
230
+ | a file with a public form | `files: { photo: { collection } }` on `c.form` | the id in the inbox; the job's `tx.files(…).attach` |
231
+ | a trigger, function or view | its migration | — |
231
232
 
232
233
  Scope every scoped table a raw statement reads with `${ctx.sql.scope(<capability>, '<table>', '<alias>')}`, a conjunct
233
234
  of its WHERE or JOIN … ON (`where (<filter>) and ${…}`): `ctx.target` for `on` and `in`, `ctx.within` for `over`, a
@@ -258,7 +259,7 @@ Which promise the types, the checker, boot, the runtime or the approver keeps, a
258
259
  - A 401, a 403 or a failing generated test: never loosen the contract, a role, a policy or a test to pass. Tell the
259
260
  human which action or role the caller lacks.
260
261
  - When the task does not already ask for it, before you add a role, a grant, an action, a declared deviation, an
261
- anonymous endpoint (`c.form`, a webhook, a link), an export or an import, or a job, or before you drop or rename a
262
+ anonymous endpoint (`c.form`, a form's `files`, a webhook, a link), an export or an import, or a job, or before you drop or rename a
262
263
  column: say in one sentence what it lets someone do, and wait for a yes. A data repair goes to the operator, not
263
264
  into a job.
264
265
  - A file refused as protected: stop and ask. Do not move the change elsewhere.
@@ -19,20 +19,20 @@ the SPA is added with it.
19
19
  "test": "bun test --isolate"
20
20
  },
21
21
  "dependencies": {
22
- "@fabrika/app": "0.5.4",
23
- "@fabrika/db": "0.5.4",
24
- "@fabrika/provider-zerops": "0.5.4",
25
- "@fabrika/proxy-core": "0.5.4",
26
- "@fabrika/ui": "0.5.4",
22
+ "@fabrika/app": "0.6.0",
23
+ "@fabrika/db": "0.6.0",
24
+ "@fabrika/provider-zerops": "0.6.0",
25
+ "@fabrika/proxy-core": "0.6.0",
26
+ "@fabrika/ui": "0.6.0",
27
27
  "kysely": "0.29.6",
28
28
  "react": "^19.0.0",
29
29
  "react-dom": "^19.0.0",
30
30
  "zod": "^3.25.76"
31
31
  },
32
32
  "devDependencies": {
33
- "@fabrika/check": "0.5.4",
34
- "@fabrika/cli": "0.5.4",
35
- "@fabrika/platform": "0.5.4",
33
+ "@fabrika/check": "0.6.0",
34
+ "@fabrika/cli": "0.6.0",
35
+ "@fabrika/platform": "0.6.0",
36
36
  "@happy-dom/global-registrator": "^20.11.2",
37
37
  "@types/bun": "latest",
38
38
  "@types/react": "^19.0.0",
@@ -325,6 +325,29 @@ CREATE INDEX fabrika_files_attachments_scan ON fabrika_files_attachments (scan_a
325
325
  for something else (a one-click answer): a mount the list does not name has no file route and need not grant
326
326
  the collection's actions. Its holder gets 403 from the proxy, which finds no endpoint for the path.
327
327
 
328
+ ## A file with a public form
329
+
330
+ A visitor without an account reaches no collection endpoint. A form takes the file with its submission instead
331
+ (`references/public-intake.md` builds it): `files: { photo: { collection: 'photos' } }` on `c.form`, into a collection
332
+ with `scan: true`, beside a challenge. The file waits owned by nobody, and nothing reads it; its id lands in the inbox
333
+ column of its field (`photo UUID`). The job that drains the inbox creates the record and attaches the file in the same
334
+ transaction, with a `Ref` of the new record for the collection's `write` action, which `tx.ref` locates in that
335
+ transaction:
336
+
337
+ ```text
338
+ const [created] = await tx.sql(Id)`insert into candidates (…) values (…) on conflict (email) do nothing returning id`
339
+ if (created !== undefined && row.photo !== null) {
340
+ await tx.files('photos').attach(await tx.ref('candidates.attach', 'candidate', created.id), row.photo)
341
+ }
342
+ ```
343
+
344
+ - Only a job's `tx.files` has `attach`; a mutation's has none. `tx.ref` is `ctx.ref` located in the transaction.
345
+ - `attach` answers `false` when no file of the collection waits under the id (none was sent, or its hold of seven days
346
+ ended) and `true` again for one the same owner attached before; it never moves another owner's file. Past
347
+ `maxPerOwner` it throws 409, and the transaction rolls back with the file still held.
348
+ - The attached file is `quarantined` until the scan job's clean verdict, like any upload of a scanning collection.
349
+ - A file nobody attached is deleted by the sweep job an hour after its hold of seven days ended.
350
+
328
351
  ## Storage
329
352
 
330
353
  On Zerops, declare one `object-storage` service in `fabrika.config.ts`; the platform writes `FABRIKA_APP_STORAGE_*`
@@ -1,7 +1,7 @@
1
1
  # references/public-intake.md — the public intake recipe
2
2
 
3
- A visitor who has no account submits a form on a public page, confirms the e-mail address through a link, attaches
4
- files, and asks for a new link when the first one is lost. HR reviews the records, an HR lead edits the code lists of
3
+ A visitor who has no account submits a form on a public page, with a photo from the phone's camera if they like,
4
+ confirms the e-mail address through a link, attaches files, and asks for a new link when the first one is lost. HR reviews the records, an HR lead edits the code lists of
5
5
  open positions and of the reasons a decision records, HR exports every candidate with an audit row, and an HR person
6
6
  checks the mail setup with a test e-mail to their own address. This file is an application of its own (`intake`); its
7
7
  `package.json`, `tsconfig.json` and `src/app-kit.ts` are those of `app-setup.md`.
@@ -136,13 +136,15 @@ CREATE TABLE candidate_events (
136
136
  );
137
137
  CREATE INDEX candidate_events_candidate ON candidate_events (candidate_id);
138
138
 
139
- -- The forms' inboxes: a column per input field, of its name, except the honeypot and the challenge token. A form
140
- -- writes its input fields only, so every other column has a default: id names the row for the job, received_at keeps
141
- -- the time of the submission, and a registration counts the job's failed attempts at it.
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.
142
143
  CREATE TABLE registrations (
143
144
  id UUID NOT NULL DEFAULT gen_random_uuid(),
144
145
  email TEXT NOT NULL,
145
146
  full_name TEXT NOT NULL,
147
+ photo UUID,
146
148
  received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
147
149
  attempts INTEGER NOT NULL DEFAULT 0,
148
150
  last_failure TEXT
@@ -214,8 +216,31 @@ CREATE INDEX fabrika_files_attachments_removable ON fabrika_files_attachments (c
214
216
  CREATE INDEX fabrika_files_attachments_scan ON fabrika_files_attachments (scan_after) WHERE status = 'quarantined';
215
217
  ```
216
218
 
217
- The file table is `fileTableSql('attachments', { table: 'candidates', key: { column: 'id', type: 'uuid' }, chain: [] })`
218
- (`references/files.md`): a top-level owner has no chain.
219
+ ```sql db/migrations/20261013090150_photos.sql
220
+ CREATE TABLE fabrika_files_photos (
221
+ 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,
229
+ 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
236
+ );
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';
240
+ ```
241
+
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.
219
244
 
220
245
  ```ts src/db/tables.ts
221
246
  import { defineTables } from '@fabrika/db/tables'
@@ -359,6 +384,7 @@ export const contract = app.contract((c) => ({
359
384
  secret: 'TURNSTILE_SECRET',
360
385
  siteKey: 'TURNSTILE_SITE_KEY',
361
386
  },
387
+ files: { photo: { collection: 'photos' } },
362
388
  then: 'intake',
363
389
  }),
364
390
  resend: c.form('/resend', {
@@ -385,6 +411,16 @@ export const contract = app.contract((c) => ({
385
411
  link: true,
386
412
  scan: true,
387
413
  }),
414
+ photos: c.files({
415
+ owner: 'candidates',
416
+ read: 'candidates.read',
417
+ write: 'candidates.attach',
418
+ delete: 'candidates.review',
419
+ maxBytes: 15728640,
420
+ types: ['image/jpeg', 'image/png', 'image/webp'],
421
+ maxPerOwner: 1,
422
+ scan: true,
423
+ }),
388
424
  },
389
425
  jobs: { intake, testMails },
390
426
  health: c.health('/healthz'),
@@ -421,6 +457,22 @@ export const contract = app.contract((c) => ({
421
457
  `styleSrc`, `fontSrc` and `frameAncestors`: exact `https://` origins, at most 16 each. Each new origin is a widening the approver sees.
422
458
  - **Scanned files.** A visitor's upload waits `quarantined` until ClamAV answers clean (`references/files.md`): its
423
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.
424
476
  - **The kick.** `then: 'intake'` makes each accepted submission start a run of the job within seconds, once its row
425
477
  committed, under the job's own key. The visitor's answer is the same constant and never waits for the run. Kicks
426
478
  coalesce on the job's lease (at most one run and one pending, however many visitors submit), so a flood adds no
@@ -505,6 +557,7 @@ const Queued = z.strictObject({
505
557
  id: z.string(),
506
558
  email: z.string(),
507
559
  fullName: z.string(),
560
+ photo: z.nullable(z.string()),
508
561
  })
509
562
  const Ask = z.strictObject({ email: z.string() })
510
563
  const Id = z.strictObject({ id: z.string() })
@@ -574,18 +627,27 @@ export const intake = app.cron('* * * * *', {
574
627
  ctx.transaction(async (tx) => {
575
628
  // Claimed where it is consumed: a row other work holds is skipped, not waited for.
576
629
  const [row] = await tx.sql(Queued)`
577
- select id, email, full_name from registrations
630
+ select id, email, full_name, photo from registrations
578
631
  where id = ${id} and attempts < ${MAX_ATTEMPTS}
579
632
  for update skip locked`
580
633
  if (row === undefined) return
581
634
  await tx.sql(Id)`
582
635
  delete from registrations where id = ${row.id} returning id`
583
- await tx.sql(Id)`
636
+ const [created] = await tx.sql(Id)`
584
637
  insert into candidates (id, email, name, created_by, version)
585
638
  values (${crypto.randomUUID()}, ${row.email}, ${row.fullName},
586
639
  ${ctx.principal.id}, 1)
587
640
  on conflict (email) do nothing
588
641
  returning id`
642
+ // The photo becomes the new candidate's in this transaction; a known address gets none.
643
+ if (created !== undefined && row.photo !== null) {
644
+ const candidate = await tx.ref(
645
+ 'candidates.attach',
646
+ 'candidate',
647
+ created.id,
648
+ )
649
+ await tx.files('photos').attach(candidate, row.photo)
650
+ }
589
651
  }), async () => {
590
652
  const [counted] = await ctx.transaction((tx) =>
591
653
  tx.sql(Attempts)`
@@ -922,9 +984,15 @@ export const siteKey = async (): Promise<string | null> => {
922
984
  ```
923
985
 
924
986
  The public page sends the form with a plain `fetch`: a form has no typed client, and its answer never depends on the
925
- data.
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.
926
990
 
927
991
  ```ts web/site/register.ts
992
+ /** What the `photos` collection admits; the page checks it before it spends the challenge. */
993
+ const PHOTO_TYPES = ['image/jpeg', 'image/png', 'image/webp']
994
+ const PHOTO_MAX_BYTES = 15728640
995
+
928
996
  export const register = async (
929
997
  form: HTMLFormElement,
930
998
  challengeToken: string,
@@ -934,16 +1002,25 @@ export const register = async (
934
1002
  const value = data.get(name)
935
1003
  return typeof value === 'string' ? value : ''
936
1004
  }
937
- const response = await fetch('/register', {
938
- method: 'POST',
939
- headers: { 'Content-Type': 'application/json' },
940
- body: JSON.stringify({
1005
+ const body = new FormData()
1006
+ body.append(
1007
+ 'input',
1008
+ JSON.stringify({
941
1009
  email: field('email'),
942
1010
  full_name: field('full_name'),
943
1011
  website: field('website'),
944
1012
  challengeToken,
945
1013
  }),
946
- })
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)
1022
+ }
1023
+ const response = await fetch('/register', { method: 'POST', body })
947
1024
  return response.ok
948
1025
  }
949
1026
  ```
@@ -1525,7 +1602,12 @@ export const testMails = app.cron('* * * * *', {
1525
1602
  job's key, so `kit.runJob('intake')` needs `testKit(app, { membership })` with an `IamMembershipRpc` of your own that
1526
1603
  answers `issueLink` and `revokeLink`. A test of a form passes `env: {
1527
1604
  TURNSTILE_SECRET: '…', TURNSTILE_SITE_KEY: '…' }` and `challengeFetch`, a stand-in for Turnstile's answer, to `testKit`, and a fresh
1528
- `challengeToken` with each submission: a token is single use. Test each stop of a delivery (a refused e-mail, IAM
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
1529
1611
  down, a crash after each step, an answer lost after the provider took the e-mail): the next run must leave exactly
1530
1612
  one live, e-mailed link and the transport one message per key. Test a bad record beside good ones too: a
1531
1613
  registration the insert refuses and a recipient whose e-mail hangs end dead after `MAX_ATTEMPTS` runs, a recipient
@@ -736,9 +736,11 @@ if (postgres !== null) {
736
736
  ```
737
737
 
738
738
  - `web/__tests__/dom.ts` comes first: imports run in order, and React reads `window` when it loads. `installDom` keeps
739
- Bun's `fetch`, `AbortController`, `URL`, `Blob`, `File` and `FormData`, because the clients, the kit, a form's
740
- challenge and a download run on them, and turns navigation off, so a download's link click leaves the page for the
741
- next test. Run the tests with `bun test --isolate`, which gives each file its own globals.
739
+ Bun's `fetch`, `AbortController`, `URL`, `Blob`, `File`, `FormData` and its `ReadableStream`, `WritableStream` and
740
+ `TransformStream`, because the clients, the kit, a form's challenge, a form's file and a download run on them, and
741
+ turns navigation off, so a download's link click leaves the page for the next test. A DOM registration of your own
742
+ keeps the same classes of Bun, or a form with a file fails in the kit. Run the tests with `bun test --isolate`, which
743
+ gives each file its own globals.
742
744
  - `await type('Title', 'report')` types into the control whose label (or `aria-label`) is `Title` as React reads a
743
745
  person typing; a select takes the option's value. `controlByLabel(label)` answers the control, and
744
746
  `waitForText(text)` waits until the page shows it.
@@ -189,10 +189,11 @@ const mount = z.discriminatedUnion('kind', [
189
189
  kind: z.literal('form'),
190
190
  path: name,
191
191
  appendTo: name,
192
- rate: z.strictObject({ perClient: formRate.optional(), perForm: formRate.optional() }).optional(),
192
+ rate: z.strictObject({ perClient: formRate.optional(), perForm: formRate.optional(), bytesPerHour: z.int().positive().optional() }).optional(),
193
193
  honeypot: name.optional(),
194
194
  challenge: z.strictObject({ provider: z.literal('turnstile'), secret: applicationVariable, siteKey: applicationVariable.optional() }).optional(),
195
195
  then: name.optional(),
196
+ files: z.record(name, z.strictObject({ collection: name, required: z.boolean() })).optional(),
196
197
  }),
197
198
  z.strictObject({ kind: z.literal('webhook'), path: name, scheme: z.literal('github-sha256'), boundTo: scopeBinding, actions }),
198
199
  ])
@@ -25,6 +25,7 @@ import {
25
25
  type ExportArtifact,
26
26
  type FileCollectionArtifact,
27
27
  type FormChallenge,
28
+ type FormFileArtifact,
28
29
  type FormMountArtifact,
29
30
  type FormRate,
30
31
  type ImportArtifact,
@@ -82,7 +83,10 @@ import {
82
83
  fileCollectionPaths,
83
84
  FILES_PREFIX,
84
85
  FORM_CHALLENGE_FIELD,
86
+ FORM_FILE_LIMITS,
87
+ FORM_INPUT_PART,
85
88
  FORM_LIMITS,
89
+ FORM_MULTIPART_TYPE,
86
90
  joinPath,
87
91
  JSON_ADMISSION,
88
92
  LINK_API_SEGMENT,
@@ -121,6 +125,8 @@ const DECLARED_ALLOWED =
121
125
  const CSP_FIELDS = ['connectSrc', 'imgSrc', 'frameAncestors', 'scriptSrc', 'frameSrc', 'styleSrc', 'fontSrc']
122
126
  /** An environment variable an application reads; a form's challenge names its secret by one. */
123
127
  const APPLICATION_VARIABLE = /^[A-Z][A-Z0-9_]*$/
128
+ /** A form's file field names the inbox column its file's id lands in. */
129
+ const FORM_FILE_FIELD = /^[a-z_][a-z0-9_]*$/
124
130
  /** Turnstile's `action`: the form's name, which the verification expects back. */
125
131
  const CHALLENGE_ACTION = /^[A-Za-z0-9_-]{1,32}$/
126
132
 
@@ -524,26 +530,29 @@ class ArtifactBuilder {
524
530
  this.#prefix(where, mount.path)
525
531
  this.#anonymous(name, 'health', 'GET', mount.path, null, null)
526
532
  return { kind: 'health', path: mount.path }
527
- case 'form':
533
+ case 'form': {
528
534
  this.#prefix(where, mount.path)
529
535
  this.#objectSchema(`${where} input`, mount.input)
530
536
  this.#nonEmpty(`${where} appendTo`, mount.appendTo)
531
- this.#anonymous(
532
- name,
533
- 'form',
534
- 'POST',
535
- mount.path,
536
- this.#bodyLimit(where, mount.maxBytes, CONTRACT_DEFAULTS.formMaxBytes),
537
- mount.input,
538
- this.#crossOrigin(where, mount.crossOrigin, true),
539
- )
537
+ const files = mount.files === undefined ? null : this.#formFiles(where, mount, mount.files)
538
+ this.#formByteBudget(where, mount, files)
539
+ const limit = this.#bodyLimit(where, mount.maxBytes, CONTRACT_DEFAULTS.formMaxBytes)
540
+ // A form that takes files is multipart: `maxBytes` bounds its input part, each collection its file. While no
541
+ // file is required it also takes the JSON body it took before, so its existing pages keep working.
542
+ const optional = files !== null && Object.values(files).every((file) => !file.required)
543
+ const admission: BodyAdmission = files === null
544
+ ? limit
545
+ : { contentTypes: optional ? ['application/json', FORM_MULTIPART_TYPE] : [FORM_MULTIPART_TYPE], maxBytes: limit.maxBytes }
546
+ this.#anonymous(name, 'form', 'POST', mount.path, admission, mount.input, this.#crossOrigin(where, mount.crossOrigin, true))
540
547
  return {
541
548
  kind: 'form',
542
549
  path: mount.path,
543
550
  appendTo: mount.appendTo,
544
551
  ...this.#formAdmission(name, where, mount),
545
552
  ...(mount.then === undefined ? {} : { then: this.#kickedJob(where, mount.then) }),
553
+ ...(files === null ? {} : { files }),
546
554
  }
555
+ }
547
556
  case 'webhook': {
548
557
  this.#prefix(where, mount.path)
549
558
  this.#objectSchema(`${where} input`, mount.input)
@@ -608,9 +617,92 @@ class ArtifactBuilder {
608
617
  return admission
609
618
  }
610
619
 
620
+ /**
621
+ * A form's file fields: at most `FORM_FILE_LIMITS.maxFields`, each a lowercase column name that no input field, the
622
+ * honeypot, the challenge token or the input part takes, each landing in a declared collection that scans. An
623
+ * anonymous visitor's file reaches the people who read the collection, so the form must declare a challenge too.
624
+ */
625
+ #formFiles(where: string, mount: FormMountDeclaration<SchemaShape>, declared: unknown): Readonly<Record<string, FormFileArtifact>> | null {
626
+ const example = "files: { photo: { collection: '<a c.files collection with scan: true>' } }"
627
+ if (typeof declared !== 'object' || declared === null || Array.isArray(declared)) {
628
+ this.#problem(where, `files is not an object of file fields (allowed: ${example})`)
629
+ return null
630
+ }
631
+ const entries = Object.entries(declared)
632
+ if (entries.length === 0 || entries.length > FORM_FILE_LIMITS.maxFields) {
633
+ this.#problem(where, `files declares ${entries.length} file fields (allowed: 1 to ${FORM_FILE_LIMITS.maxFields}, one file each)`)
634
+ }
635
+ if (mount.challenge === undefined) {
636
+ this.#problem(where, "takes files, so it declares a challenge: an anonymous upload needs a solved one (challenge: { provider: 'turnstile', … })")
637
+ }
638
+ const fields = mount.input.type === 'object' ? mount.input.fields : {}
639
+ const files: Record<string, FormFileArtifact> = {}
640
+ for (const [field, option] of entries) {
641
+ const at = `${where} file '${field}'`
642
+ if (!FORM_FILE_FIELD.test(field)) {
643
+ this.#problem(at, 'is not a column name (allowed: lowercase snake_case, as its inbox column is named)')
644
+ }
645
+ if (field === FORM_INPUT_PART || field === FORM_CHALLENGE_FIELD || field === mount.honeypot || Object.hasOwn(fields, field)) {
646
+ this.#problem(at, `is also the name of the input part, the challenge token, the honeypot or an input field; name the file its own field`)
647
+ }
648
+ if (typeof option !== 'object' || option === null || Array.isArray(option)) {
649
+ this.#problem(at, `is not { collection, required? } (allowed: ${example})`)
650
+ continue
651
+ }
652
+ this.#keys(at, option, ['collection', 'required'])
653
+ const collection: unknown = Reflect.get(option, 'collection')
654
+ const required: unknown = Reflect.get(option, 'required')
655
+ if (required !== undefined && typeof required !== 'boolean') {
656
+ this.#problem(at, `required ${JSON.stringify(required)} is not true or false`)
657
+ }
658
+ const collections = this.#contract.files
659
+ const target: { readonly kind: string; readonly scan?: boolean } | undefined =
660
+ typeof collection === 'string' && Object.hasOwn(collections, collection) ? collections[collection] : undefined
661
+ if (typeof collection !== 'string' || target === undefined || target.kind !== 'files') {
662
+ const names = Object.keys(collections)
663
+ this.#problem(
664
+ at,
665
+ `collection ${JSON.stringify(collection)} is not a file collection of the contract (collections: ${
666
+ names.length === 0 ? 'none' : names.join(', ')
667
+ })`,
668
+ )
669
+ continue
670
+ }
671
+ if (target.scan !== true) {
672
+ this.#problem(at, `lands in files '${collection}', which does not scan; an anonymous visitor's file waits for a clean verdict (scan: true)`)
673
+ }
674
+ files[field] = { collection, required: required === true }
675
+ }
676
+ return files
677
+ }
678
+
679
+ /** `bytesPerHour` belongs to a form with files, and fits at least the largest file one of its fields admits. */
680
+ #formByteBudget(where: string, mount: FormMountDeclaration<SchemaShape>, files: Readonly<Record<string, FormFileArtifact>> | null): void {
681
+ const declared: unknown = typeof mount.rate === 'object' && mount.rate !== null ? Reflect.get(mount.rate, 'bytesPerHour') : undefined
682
+ if (declared === undefined) {
683
+ return
684
+ }
685
+ if (files === null) {
686
+ this.#problem(where, 'declares rate.bytesPerHour and takes no files (allowed: on a form with files)')
687
+ return
688
+ }
689
+ const largest = Math.max(...Object.values(files).map((file) => this.#contract.files[file.collection]?.maxBytes ?? 0))
690
+ if (typeof declared === 'number' && declared < largest) {
691
+ this.#problem(where, `rate.bytesPerHour ${declared} is less than the ${largest} bytes one of its files may hold`)
692
+ }
693
+ }
694
+
611
695
  #formRate(where: string, rate: object): FormRate {
612
- this.#keys(where, rate, ['perClient', 'perForm'])
613
- const parsed: { perClient?: number; perForm?: number } = {}
696
+ this.#keys(where, rate, ['perClient', 'perForm', 'bytesPerHour'])
697
+ const parsed: { perClient?: number; perForm?: number; bytesPerHour?: number } = {}
698
+ const bytes: unknown = Reflect.get(rate, 'bytesPerHour')
699
+ if (bytes !== undefined) {
700
+ if (typeof bytes !== 'number' || !Number.isSafeInteger(bytes) || bytes < 1 || bytes > FORM_FILE_LIMITS.maxBytesPerHour) {
701
+ this.#problem(where, `bytesPerHour ${String(bytes)} is not a whole number of bytes from 1 to ${FORM_FILE_LIMITS.maxBytesPerHour}`)
702
+ } else {
703
+ parsed.bytesPerHour = bytes
704
+ }
705
+ }
614
706
  for (const key of ['perClient', 'perForm'] as const) {
615
707
  const value: unknown = Reflect.get(rate, key)
616
708
  if (value === undefined) {
@@ -1782,14 +1874,15 @@ class ArtifactBuilder {
1782
1874
  if (columns === undefined || mount.input.type !== 'object') {
1783
1875
  return
1784
1876
  }
1785
- for (const field of Object.keys(mount.input.fields)) {
1877
+ const fileFields = typeof mount.files === 'object' && mount.files !== null ? Object.keys(mount.files) : []
1878
+ for (const field of [...Object.keys(mount.input.fields), ...fileFields]) {
1786
1879
  const exempt = field === mount.honeypot || (mount.challenge !== undefined && field === FORM_CHALLENGE_FIELD)
1787
1880
  if (!exempt && !columns.includes(field)) {
1788
1881
  this.#problem(
1789
1882
  `mount '${name}'`,
1790
- `the input field '${field}' is not a column of its inbox ${mount.appendTo}${suggestion(field, columns)} (allowed: a field named like a column: ${
1791
- columns.join(', ')
1792
- })`,
1883
+ `the ${fileFields.includes(field) ? 'file' : 'input'} field '${field}' is not a column of its inbox ${mount.appendTo}${
1884
+ suggestion(field, columns)
1885
+ } (allowed: a field named like a column: ${columns.join(', ')})`,
1793
1886
  )
1794
1887
  }
1795
1888
  }
@@ -18,7 +18,7 @@ import type {
18
18
  WithinTables,
19
19
  } from '@fabrika/db/tables'
20
20
  import type { z } from 'zod/v4'
21
- import type { FileCollectionHandle, FileCollectionReadHandle, FileCollections } from '../files/handle'
21
+ import type { FileCollectionHandle, FileCollectionJobHandle, FileCollectionReadHandle, FileCollections } from '../files/handle'
22
22
  import type { AppCapability, Ref, Within } from '../implement/capability'
23
23
  import type { ApplicationDirectory, LinkIssuing, MemberDirectory, MemberLookup, MemberManagement } from '../implement/membership'
24
24
  import type { AppPrincipal, PrincipalOf, ServicePrincipal } from '../implement/principal'
@@ -135,12 +135,14 @@ type SqlContext<S, Ix extends AnyDbIndex> = {
135
135
  }
136
136
 
137
137
  /** `ctx.files(<collection>)`: removal where the site writes (a mutation, a job's transaction), reading everywhere. */
138
- type FilesContext<Ix, K extends DataKind> = {
138
+ type FilesContext<Ix, K extends DataKind | 'transaction'> = {
139
139
  readonly files: Ix extends NoTablesIndex ? Unavailable<'this application declares no tables, so it has no file collections'>
140
- : FileCollections<K extends 'mutation' ? FileCollectionHandle : FileCollectionReadHandle<RemovesIn<K>>>
140
+ : FileCollections<
141
+ K extends 'transaction' ? FileCollectionJobHandle : K extends 'mutation' ? FileCollectionHandle : FileCollectionReadHandle<RemovesIn<K>>
142
+ >
141
143
  }
142
144
 
143
- type RemovesIn<K extends DataKind> = K extends 'job' ? 'a job removes files inside ctx.transaction((tx) => …), as tx.files'
145
+ type RemovesIn<K extends DataKind | 'transaction'> = K extends 'job' ? 'a job removes files inside ctx.transaction((tx) => …), as tx.files'
144
146
  : 'a query reads only; remove files in a mutation'
145
147
 
146
148
  type UnscopedData<Ix extends AnyDbIndex, U, K extends DataKind> = {
@@ -395,7 +397,14 @@ type SiteData<Ix extends AnyDbIndex, Kind extends DataKind, Acts extends string,
395
397
  export type JobTransaction<K extends KitTypes, A extends string, D extends Declared<SqlMode, unknown, never, string, JobMailer>> =
396
398
  & { readonly db: SiteData<K['index'], 'mutation', A, Of<D, 'unscoped'>> }
397
399
  & SqlContext<Of<D, 'sql'>, K['index']>
398
- & FilesContext<K['index'], 'mutation'>
400
+ & FilesContext<K['index'], 'transaction'>
401
+ & {
402
+ /**
403
+ * `ctx.ref`, located in this transaction: an object it wrote is found before the commit, so the same transaction
404
+ * attaches a form's file to the record it just created.
405
+ */
406
+ ref<const Act extends A, const Dim extends K['dimension']>(action: Act, dimension: Dim, id: string): Promise<Ref<Dim, Act>>
407
+ }
399
408
 
400
409
  /**
401
410
  * `ctx.links` of a cron job that declares the link mount it issues (`issues: '<mount>'`): a link for an object the job
@@ -51,7 +51,7 @@ export const BUILDER_FIELDS: Readonly<Record<Builder, BuilderFields>> = {
51
51
  rpc: { options: ['callers'], owned: ['kind', 'prefix', 'procedures'] },
52
52
  link: { options: ['spa', 'dimension', 'grants', 'issuedBy', 'maxDays', 'procedures'], owned: ['kind', 'prefix'] },
53
53
  spa: { options: ['dir', 'access', 'public'], owned: ['kind', 'prefix'] },
54
- form: { options: ['input', 'appendTo', 'maxBytes', 'rate', 'honeypot', 'challenge', 'then', 'crossOrigin'], owned: ['kind', 'path'] },
54
+ form: { options: ['input', 'appendTo', 'maxBytes', 'rate', 'honeypot', 'challenge', 'then', 'crossOrigin', 'files'], owned: ['kind', 'path'] },
55
55
  webhook: { options: ['scheme', 'boundTo', 'actions', 'input', 'maxBytes'], owned: ['kind', 'path'] },
56
56
  files: { options: ['owner', 'read', 'write', 'delete', 'maxBytes', 'types', 'maxPerOwner', 'link', 'scan'], owned: ['kind'] },
57
57
  cron: { options: ['actions', 'scope', 'timeout', 'retries'], owned: ['kind', 'schedule'] },
@@ -228,8 +228,11 @@ export interface FormMountDeclaration<S> extends OptionsRecord {
228
228
  readonly input: S
229
229
  readonly appendTo: string
230
230
  readonly maxBytes?: number
231
- /** Submissions per minute from one client and accepted in all; `FORM_LIMITS` holds the defaults. */
232
- readonly rate?: { readonly perClient?: number; readonly perForm?: number }
231
+ /**
232
+ * Submissions per minute from one client and accepted in all, and the bytes a form with files writes per hour;
233
+ * `FORM_LIMITS` and `FORM_FILE_LIMITS` hold the defaults.
234
+ */
235
+ readonly rate?: { readonly perClient?: number; readonly perForm?: number; readonly bytesPerHour?: number }
233
236
  /** An input field that must stay empty. */
234
237
  readonly honeypot?: string
235
238
  /**
@@ -241,6 +244,8 @@ export interface FormMountDeclaration<S> extends OptionsRecord {
241
244
  readonly then?: string
242
245
  /** `true` (the environment's listed origins) or `'*'` (any origin), as written; `buildArtifact` refuses anything else. */
243
246
  readonly crossOrigin?: unknown
247
+ /** The file fields, by name: `{ collection, required? }` each, as written; `buildArtifact` checks them. */
248
+ readonly files?: unknown
244
249
  }
245
250
 
246
251
  export interface WebhookMountDeclaration<S> extends OptionsRecord {