@fabrika/app 0.5.3 → 0.5.5
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 +9 -9
- package/skill/SKILL.md +11 -10
- package/skill/references/app-setup.md +8 -8
- package/skill/references/files.md +23 -0
- package/skill/references/public-intake.md +98 -16
- package/skill/references/spa.md +5 -3
- package/src/contract/artifact-schema.ts +2 -1
- package/src/contract/build.ts +109 -16
- package/src/contract/context.ts +14 -5
- package/src/contract/declaration.ts +8 -3
- package/src/contract/defaults.ts +27 -0
- package/src/contract/define.ts +15 -1
- package/src/contract/kit.ts +8 -2
- package/src/files/collections.ts +195 -16
- package/src/files/handle.ts +21 -2
- package/src/implement/anonymous.ts +15 -2
- package/src/implement/boot.ts +31 -6
- package/src/implement/dispatch.ts +194 -5
- package/src/implement/implement.ts +6 -2
- package/src/implement/invocation.ts +1 -0
- package/src/implement/multipart.ts +332 -0
- package/src/jobs/runner.ts +38 -15
- package/src/react/testing.ts +3 -0
- package/src/testing/endpoint-access.ts +12 -6
- package/src/testing/kit.ts +16 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/app",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.5",
|
|
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.
|
|
97
|
-
"@fabrika/auth-core": "0.5.
|
|
98
|
-
"@fabrika/db": "0.5.
|
|
99
|
-
"@fabrika/email": "0.5.
|
|
100
|
-
"@fabrika/jwks": "0.5.
|
|
101
|
-
"@fabrika/platform": "0.5.
|
|
102
|
-
"@fabrika/platform-node": "0.5.
|
|
103
|
-
"@fabrika/proxy-core": "0.5.
|
|
96
|
+
"@fabrika/auth": "0.5.5",
|
|
97
|
+
"@fabrika/auth-core": "0.5.5",
|
|
98
|
+
"@fabrika/db": "0.5.5",
|
|
99
|
+
"@fabrika/email": "0.5.5",
|
|
100
|
+
"@fabrika/jwks": "0.5.5",
|
|
101
|
+
"@fabrika/platform": "0.5.5",
|
|
102
|
+
"@fabrika/platform-node": "0.5.5",
|
|
103
|
+
"@fabrika/proxy-core": "0.5.5",
|
|
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.
|
|
3
|
+
version: 0.5.5
|
|
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
|
|
224
|
-
| ------------------------------ |
|
|
225
|
-
| a join, a report, a bulk write | `sql: 'read'` (or `'write'`)
|
|
226
|
-
| rows outside its capability | `unscoped: { <table>: 'read' }`
|
|
227
|
-
| another application's export | `connect: ['<import>']`
|
|
228
|
-
| a share link | `issues: '<link mount>'`
|
|
229
|
-
| e-mail (a cron job only) | `mailer: 'installation'`
|
|
230
|
-
| a
|
|
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.
|
|
23
|
-
"@fabrika/db": "0.5.
|
|
24
|
-
"@fabrika/provider-zerops": "0.5.
|
|
25
|
-
"@fabrika/proxy-core": "0.5.
|
|
26
|
-
"@fabrika/ui": "0.5.
|
|
22
|
+
"@fabrika/app": "0.5.5",
|
|
23
|
+
"@fabrika/db": "0.5.5",
|
|
24
|
+
"@fabrika/provider-zerops": "0.5.5",
|
|
25
|
+
"@fabrika/proxy-core": "0.5.5",
|
|
26
|
+
"@fabrika/ui": "0.5.5",
|
|
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.
|
|
34
|
-
"@fabrika/cli": "0.5.
|
|
35
|
-
"@fabrika/platform": "0.5.
|
|
33
|
+
"@fabrika/check": "0.5.5",
|
|
34
|
+
"@fabrika/cli": "0.5.5",
|
|
35
|
+
"@fabrika/platform": "0.5.5",
|
|
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,
|
|
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
|
|
140
|
-
--
|
|
141
|
-
--
|
|
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
|
-
|
|
218
|
-
|
|
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
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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.
|
|
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
|
package/skill/references/spa.md
CHANGED
|
@@ -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 `
|
|
740
|
-
|
|
741
|
-
|
|
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
|
])
|
package/src/contract/build.ts
CHANGED
|
@@ -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.#
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
-
|
|
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}${
|
|
1791
|
-
|
|
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
|
}
|
package/src/contract/context.ts
CHANGED
|
@@ -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<
|
|
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'], '
|
|
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
|
-
/**
|
|
232
|
-
|
|
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 {
|