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