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