@fabrika/app 0.3.2 → 0.4.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/README.md +78 -90
- package/package.json +68 -6
- package/skill/SKILL.md +319 -0
- package/skill/references/app-setup.md +364 -0
- package/skill/references/dev.md +67 -0
- package/skill/references/endpoint.md +627 -0
- package/skill/references/errors.md +108 -0
- package/skill/references/files.md +335 -0
- package/skill/references/jobs.md +169 -0
- package/skill/references/public-intake.md +1495 -0
- package/skill/references/spa.md +741 -0
- package/skill/references/start.md +179 -0
- package/skill/references/table-and-migration.md +264 -0
- package/skill/references/tests.md +305 -0
- package/skill/references/webhooks-links-connectors.md +190 -0
- package/src/agent-skill.ts +77 -0
- package/src/build.ts +53 -0
- package/src/bun.ts +302 -3
- package/src/client/caller.ts +24 -0
- package/src/client/contract.ts +309 -0
- package/src/client/files.ts +135 -0
- package/src/client/index.ts +19 -0
- package/src/client/rpc.ts +47 -0
- package/src/client/share.ts +14 -0
- package/src/{client.ts → client/transport.ts} +58 -61
- package/src/cloudflare.ts +139 -3
- package/src/contract/artifact-schema.ts +317 -0
- package/src/contract/attachment.ts +74 -0
- package/src/contract/build.ts +1936 -0
- package/src/contract/compile.ts +143 -0
- package/src/contract/connect.ts +170 -0
- package/src/contract/context.ts +437 -0
- package/src/contract/declaration.ts +310 -0
- package/src/contract/defaults.ts +156 -0
- package/src/contract/define.ts +567 -0
- package/src/contract/file-schemas.ts +25 -0
- package/src/contract/group.ts +263 -0
- package/src/contract/handled.ts +50 -0
- package/src/contract/index.ts +34 -0
- package/src/contract/kit.ts +607 -0
- package/src/contract/platform.ts +113 -0
- package/src/contract/problems.ts +30 -0
- package/src/contract/schema-from-shape.ts +152 -0
- package/src/contract/schema-shape.ts +407 -0
- package/src/contract/snapshot-source.ts +168 -0
- package/src/contract/vocabulary.ts +50 -0
- package/src/disk-assets.ts +25 -0
- package/src/fetch.ts +10 -0
- package/src/files/archive.ts +292 -0
- package/src/files/clamscan.ts +253 -0
- package/src/files/collections.ts +669 -0
- package/src/files/handle.ts +77 -0
- package/src/files/scan.ts +308 -0
- package/src/files/sniff.ts +70 -0
- package/src/files/treatment.ts +48 -0
- package/src/from-row.ts +53 -0
- package/src/implement/anonymous.ts +102 -0
- package/src/implement/attachment.ts +143 -0
- package/src/implement/boot.ts +808 -0
- package/src/implement/bounded.ts +72 -0
- package/src/implement/browser-config.ts +54 -0
- package/src/implement/capability.ts +439 -0
- package/src/implement/challenge.ts +115 -0
- package/src/implement/connect.ts +386 -0
- package/src/implement/dispatch.ts +1085 -0
- package/src/implement/environment.ts +47 -0
- package/src/implement/failures.ts +116 -0
- package/src/implement/faults.ts +46 -0
- package/src/implement/form-admission.ts +120 -0
- package/src/implement/headers.ts +17 -0
- package/src/implement/implement.ts +81 -0
- package/src/implement/index.ts +60 -0
- package/src/implement/invocation.ts +346 -0
- package/src/implement/membership.ts +485 -0
- package/src/implement/principal.ts +99 -0
- package/src/implement/project.ts +76 -0
- package/src/implement/serve.ts +142 -0
- package/src/implement/types.ts +215 -0
- package/src/implement/webhook.ts +108 -0
- package/src/implement/workload.ts +160 -0
- package/src/implement/xlsx.ts +275 -0
- package/src/index.ts +12 -13
- package/src/jobs/report.ts +134 -0
- package/src/jobs/runner.ts +749 -0
- package/src/jobs/schedule.ts +129 -0
- package/src/jobs/types.ts +44 -0
- package/src/react/caller.tsx +60 -0
- package/src/react/errors.ts +82 -0
- package/src/react/hooks.ts +239 -0
- package/src/react/index.ts +31 -0
- package/src/react/provider.tsx +165 -0
- package/src/react/store.ts +215 -0
- package/src/react/types.ts +40 -0
- package/src/rpc/dispatcher.ts +4 -19
- package/src/service/define.ts +121 -0
- package/src/service/index.ts +21 -0
- package/src/service/serve.ts +99 -0
- package/src/testing/caller.ts +123 -0
- package/src/testing/dev.ts +73 -0
- package/src/testing/endpoint-access.ts +806 -0
- package/src/testing/index.ts +23 -0
- package/src/testing/isolation.ts +25 -0
- package/src/testing/kit.ts +552 -0
- package/src/testing/membership.ts +747 -0
- package/src/testing/policy-isolation.ts +827 -0
- package/src/testing/preload.ts +6 -0
- package/src/testing/proxy.ts +87 -0
- package/src/testing/runner.ts +61 -0
- package/src/testing/status.ts +19 -0
- package/src/undumpable.ts +42 -0
- package/src/app.ts +0 -76
- package/src/middleware.ts +0 -38
- package/src/router.ts +0 -149
- package/src/testing.ts +0 -119
|
@@ -0,0 +1,1495 @@
|
|
|
1
|
+
# references/public-intake.md — the public intake recipe
|
|
2
|
+
|
|
3
|
+
A visitor who has no account submits a form on a public page, confirms the e-mail address through a link, attaches
|
|
4
|
+
files, and asks for a new link when the first one is lost. HR reviews the records, an HR lead edits the code lists of
|
|
5
|
+
open positions and of the reasons a decision records, HR exports every candidate with an audit row, and a member
|
|
6
|
+
checks the mail setup with a test e-mail to their own address. This file is an application of its own (`intake`); its
|
|
7
|
+
`package.json`, `tsconfig.json` and `src/app-kit.ts` are those of `app-setup.md`.
|
|
8
|
+
|
|
9
|
+
Every piece lets in someone who is not signed in, so ask the human before you build it, and say what each one lets the
|
|
10
|
+
visitor do. The visitor never reaches a handler without a credential:
|
|
11
|
+
|
|
12
|
+
1. **A public page** (`c.spa('/', { access: { public: true } })`) holds the form. It reads no data.
|
|
13
|
+
2. **A form** appends one row to its inbox table and answers `{ result: { received: true } }`, for a new and a known
|
|
14
|
+
address alike. It runs no handler: a handler would give every anonymous request a transaction, and a different
|
|
15
|
+
answer for a known address would tell a stranger who registered.
|
|
16
|
+
3. **A cron job** drains the inbox, creates the record, issues a link bound to it (`issues`), and sends the e-mail.
|
|
17
|
+
Each form kicks it (`then: 'intake'`), so it runs within seconds of a submission; its schedule is the recovery.
|
|
18
|
+
It is the outbox recipe of `references/jobs.md`: bounded batches, each record its own work with an attempt count,
|
|
19
|
+
a dead state for a record that keeps failing, and an idempotency key on the e-mail.
|
|
20
|
+
4. **A link mount** serves the visitor's own record: `verify` and the record's files, under the link's key.
|
|
21
|
+
5. **A second form** asks for a new link. The job revokes the old one and sends a new one, within a budget per address.
|
|
22
|
+
|
|
23
|
+
## The vocabulary, the migrations and the policies
|
|
24
|
+
|
|
25
|
+
```ts src/vocabulary.ts
|
|
26
|
+
import { defineVocabulary } from '@fabrika/app/contract'
|
|
27
|
+
|
|
28
|
+
export const vocabulary = defineVocabulary({
|
|
29
|
+
app: 'intake',
|
|
30
|
+
dimensions: { candidate: {} },
|
|
31
|
+
actions: {
|
|
32
|
+
'candidates.read': 'Read candidate records and their files',
|
|
33
|
+
'candidates.export': 'Download every candidate as a file',
|
|
34
|
+
'candidates.review': 'Move a candidate through the review',
|
|
35
|
+
'candidates.verify': "Confirm a candidate's e-mail address",
|
|
36
|
+
'candidates.attach': 'Attach files to a candidate record',
|
|
37
|
+
'candidates.invite': "Issue a candidate's portal link",
|
|
38
|
+
'candidates.process': 'Register candidates and send their links',
|
|
39
|
+
'positions.read': 'Read the open positions',
|
|
40
|
+
'positions.manage': 'Edit the open positions',
|
|
41
|
+
'reasons.manage': 'Edit the reasons a decision records',
|
|
42
|
+
'exports.read': 'Read the log of candidate exports',
|
|
43
|
+
'people.list': "List the application's people",
|
|
44
|
+
'people.manage': "Grant and revoke the application's roles",
|
|
45
|
+
},
|
|
46
|
+
roles: {
|
|
47
|
+
hr: {
|
|
48
|
+
grants: [
|
|
49
|
+
'candidates.read',
|
|
50
|
+
'candidates.review',
|
|
51
|
+
'positions.read',
|
|
52
|
+
'people.list',
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
hr_lead: {
|
|
56
|
+
grants: [
|
|
57
|
+
'candidates.review',
|
|
58
|
+
'candidates.export',
|
|
59
|
+
'positions.read',
|
|
60
|
+
'positions.manage',
|
|
61
|
+
'reasons.manage',
|
|
62
|
+
'exports.read',
|
|
63
|
+
'people.list',
|
|
64
|
+
],
|
|
65
|
+
},
|
|
66
|
+
hr_admin: {
|
|
67
|
+
grants: [
|
|
68
|
+
'candidates.read',
|
|
69
|
+
'candidates.review',
|
|
70
|
+
'positions.read',
|
|
71
|
+
'people.list',
|
|
72
|
+
'people.manage',
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
members: { manage: 'people.manage', list: 'people.list' },
|
|
77
|
+
directory: 'labels',
|
|
78
|
+
})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
- `candidate` is a top-level dimension with no members: a candidate is a record, not a scope people are invited to.
|
|
82
|
+
- `hr` and `hr_lead` have no `member`, so an administrator grants them in IAM, usually without a scope (a global
|
|
83
|
+
grant). The code list and the export of every candidate need that grant: they are data of the whole application.
|
|
84
|
+
- `members` names the application's own people: everyone holding one of its roles. A holder of `people.manage`
|
|
85
|
+
(`hr_admin`) grants and revokes `hr` in IAM's console without being an IAM administrator, and IAM audits each
|
|
86
|
+
change; it gives only a role whose every action it holds itself, so `hr_admin` cannot give `hr_lead`. The
|
|
87
|
+
application builds no role management of its own.
|
|
88
|
+
- `directory: 'labels'` lets every `people.list` holder read each person's label, for naming the author of a change.
|
|
89
|
+
A label spells a user's address, so the approver reviews it closely.
|
|
90
|
+
- The link's holder and the job get their actions from the link mount and the job's `actions`, not from a role.
|
|
91
|
+
|
|
92
|
+
```sql db/migrations/20261013090000_intake.sql
|
|
93
|
+
CREATE TYPE candidate_status AS ENUM ('new', 'screening', 'interview', 'hired', 'rejected');
|
|
94
|
+
CREATE TYPE link_state AS ENUM ('due', 'issued', 'sent', 'failed');
|
|
95
|
+
|
|
96
|
+
-- The link delivery is the intake job's own state: each step is recorded here before the next effect starts.
|
|
97
|
+
-- attempts counts the failed attempts of the current delivery and last_failure names the step that failed;
|
|
98
|
+
-- a delivery that failed five times is 'failed', which an operator sees. link_seal holds the issued link's URL,
|
|
99
|
+
-- sealed, until the e-mail is known sent, so a retry sends the same link under the same idempotency key.
|
|
100
|
+
CREATE TABLE candidates (
|
|
101
|
+
id UUID PRIMARY KEY,
|
|
102
|
+
email TEXT NOT NULL UNIQUE,
|
|
103
|
+
name TEXT NOT NULL,
|
|
104
|
+
status candidate_status NOT NULL DEFAULT 'new',
|
|
105
|
+
verified_at TIMESTAMPTZ,
|
|
106
|
+
link_state link_state NOT NULL DEFAULT 'due',
|
|
107
|
+
link_id TEXT,
|
|
108
|
+
link_seal TEXT,
|
|
109
|
+
revoke_link_id TEXT,
|
|
110
|
+
sends INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
window_started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
112
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
113
|
+
last_failure TEXT,
|
|
114
|
+
created_by TEXT NOT NULL,
|
|
115
|
+
version INTEGER NOT NULL
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
-- The review's history: one row per decision, written in the decision's own transaction.
|
|
119
|
+
CREATE TABLE candidate_events (
|
|
120
|
+
id UUID PRIMARY KEY,
|
|
121
|
+
candidate_id UUID NOT NULL REFERENCES candidates (id) ON DELETE CASCADE,
|
|
122
|
+
status candidate_status NOT NULL,
|
|
123
|
+
note TEXT,
|
|
124
|
+
reason_id UUID,
|
|
125
|
+
created_by TEXT NOT NULL,
|
|
126
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
127
|
+
);
|
|
128
|
+
CREATE INDEX candidate_events_candidate ON candidate_events (candidate_id);
|
|
129
|
+
|
|
130
|
+
-- The forms' inboxes: a column per input field, of its name, except the honeypot and the challenge token. A form
|
|
131
|
+
-- writes its input fields only, so every other column has a default: id names the row for the job, received_at keeps
|
|
132
|
+
-- the time of the submission, and a registration counts the job's failed attempts at it.
|
|
133
|
+
CREATE TABLE registrations (
|
|
134
|
+
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
135
|
+
email TEXT NOT NULL,
|
|
136
|
+
full_name TEXT NOT NULL,
|
|
137
|
+
received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
138
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
139
|
+
last_failure TEXT
|
|
140
|
+
);
|
|
141
|
+
CREATE TABLE resend_requests (
|
|
142
|
+
id UUID NOT NULL DEFAULT gen_random_uuid(),
|
|
143
|
+
email TEXT NOT NULL,
|
|
144
|
+
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
145
|
+
);
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
```sql db/migrations/20261013090050_application_data.sql
|
|
149
|
+
-- Data of the whole application: the code lists an HR lead edits, and the log of every export of all candidates.
|
|
150
|
+
CREATE TABLE positions (
|
|
151
|
+
id UUID PRIMARY KEY,
|
|
152
|
+
title TEXT NOT NULL,
|
|
153
|
+
active BOOLEAN NOT NULL DEFAULT true,
|
|
154
|
+
created_by TEXT NOT NULL,
|
|
155
|
+
version INTEGER NOT NULL
|
|
156
|
+
);
|
|
157
|
+
CREATE TABLE reasons (
|
|
158
|
+
id UUID PRIMARY KEY,
|
|
159
|
+
label TEXT NOT NULL,
|
|
160
|
+
active BOOLEAN NOT NULL DEFAULT true,
|
|
161
|
+
created_by TEXT NOT NULL,
|
|
162
|
+
version INTEGER NOT NULL
|
|
163
|
+
);
|
|
164
|
+
CREATE TABLE candidate_exports (
|
|
165
|
+
id UUID PRIMARY KEY,
|
|
166
|
+
row_count INTEGER NOT NULL,
|
|
167
|
+
created_by TEXT NOT NULL,
|
|
168
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
169
|
+
);
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```sql db/migrations/20261013090100_attachments.sql
|
|
173
|
+
CREATE TABLE fabrika_files_attachments (
|
|
174
|
+
id UUID PRIMARY KEY,
|
|
175
|
+
owner_id UUID,
|
|
176
|
+
status TEXT NOT NULL CONSTRAINT fabrika_files_attachments_status CHECK (status IN ('pending', 'live', 'quarantined', 'rejected')),
|
|
177
|
+
object_key TEXT NOT NULL UNIQUE,
|
|
178
|
+
size BIGINT,
|
|
179
|
+
content_type TEXT NOT NULL,
|
|
180
|
+
sha256 TEXT,
|
|
181
|
+
original_name TEXT,
|
|
182
|
+
created_by TEXT NOT NULL,
|
|
183
|
+
created_at TIMESTAMPTZ NOT NULL,
|
|
184
|
+
deleted_at TIMESTAMPTZ,
|
|
185
|
+
object_version TEXT,
|
|
186
|
+
scan_attempts INTEGER NOT NULL DEFAULT 0,
|
|
187
|
+
scan_after TIMESTAMPTZ,
|
|
188
|
+
FOREIGN KEY (owner_id) REFERENCES candidates (id) ON UPDATE CASCADE ON DELETE SET NULL
|
|
189
|
+
);
|
|
190
|
+
CREATE INDEX fabrika_files_attachments_owner ON fabrika_files_attachments (owner_id) WHERE deleted_at IS NULL;
|
|
191
|
+
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';
|
|
192
|
+
CREATE INDEX fabrika_files_attachments_scan ON fabrika_files_attachments (scan_after) WHERE status = 'quarantined';
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
The file table is `fileTableSql('attachments', { table: 'candidates', key: { column: 'id', type: 'uuid' }, chain: [] })`
|
|
196
|
+
(`references/files.md`): a top-level owner has no chain.
|
|
197
|
+
|
|
198
|
+
```ts src/db/tables.ts
|
|
199
|
+
import { defineTables } from '@fabrika/db/tables'
|
|
200
|
+
import { vocabulary } from '../vocabulary'
|
|
201
|
+
import { columns } from './schema.generated'
|
|
202
|
+
|
|
203
|
+
export const tables = defineTables(vocabulary, columns)({
|
|
204
|
+
candidates: {
|
|
205
|
+
scope: { dimension: 'candidate' },
|
|
206
|
+
actions: {
|
|
207
|
+
read: 'candidates.read',
|
|
208
|
+
insert: 'candidates.process',
|
|
209
|
+
update: 'candidates.verify',
|
|
210
|
+
appRead: ['candidates.export'],
|
|
211
|
+
},
|
|
212
|
+
insert: ['email', 'name'],
|
|
213
|
+
update: ['verified_at'],
|
|
214
|
+
version: 'version',
|
|
215
|
+
createdBy: 'created_by',
|
|
216
|
+
columns: {
|
|
217
|
+
status: { write: 'candidates.review' },
|
|
218
|
+
link_state: { read: 'candidates.process' },
|
|
219
|
+
link_id: { read: 'candidates.process' },
|
|
220
|
+
link_seal: { read: 'candidates.process' },
|
|
221
|
+
revoke_link_id: { read: 'candidates.process' },
|
|
222
|
+
sends: { read: 'candidates.process' },
|
|
223
|
+
window_started_at: { read: 'candidates.process' },
|
|
224
|
+
attempts: { read: 'candidates.process' },
|
|
225
|
+
last_failure: { read: 'candidates.process' },
|
|
226
|
+
},
|
|
227
|
+
},
|
|
228
|
+
candidate_events: {
|
|
229
|
+
scope: { child: 'candidates' },
|
|
230
|
+
actions: { read: 'candidates.read', insert: 'candidates.review' },
|
|
231
|
+
insert: ['status', 'note', 'reason_id'],
|
|
232
|
+
createdBy: 'created_by',
|
|
233
|
+
},
|
|
234
|
+
registrations: { scope: { inbox: 'register', read: 'candidates.process' } },
|
|
235
|
+
resend_requests: { scope: { inbox: 'resend', read: 'candidates.process' } },
|
|
236
|
+
positions: {
|
|
237
|
+
scope: { app: 'the open positions an HR lead edits' },
|
|
238
|
+
actions: {
|
|
239
|
+
read: 'positions.read',
|
|
240
|
+
insert: 'positions.manage',
|
|
241
|
+
update: 'positions.manage',
|
|
242
|
+
},
|
|
243
|
+
insert: ['title'],
|
|
244
|
+
update: ['title', 'active'],
|
|
245
|
+
version: 'version',
|
|
246
|
+
createdBy: 'created_by',
|
|
247
|
+
},
|
|
248
|
+
reasons: {
|
|
249
|
+
scope: { app: 'the reasons a decision records, which an HR lead edits' },
|
|
250
|
+
actions: {
|
|
251
|
+
read: 'candidates.review',
|
|
252
|
+
insert: 'reasons.manage',
|
|
253
|
+
update: 'reasons.manage',
|
|
254
|
+
},
|
|
255
|
+
insert: ['label'],
|
|
256
|
+
update: ['label', 'active'],
|
|
257
|
+
version: 'version',
|
|
258
|
+
createdBy: 'created_by',
|
|
259
|
+
},
|
|
260
|
+
candidate_exports: {
|
|
261
|
+
scope: { app: 'the log of every export of all candidates' },
|
|
262
|
+
actions: { read: 'exports.read', insert: 'candidates.export' },
|
|
263
|
+
insert: ['row_count'],
|
|
264
|
+
createdBy: 'created_by',
|
|
265
|
+
},
|
|
266
|
+
})
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
- `{ inbox: '<form>', read }` is a form's table: only that form appends to it, and only a site that holds `read` and
|
|
270
|
+
declares the reach reads it. No site writes it: `unscoped: { registrations: 'write' }` is refused.
|
|
271
|
+
- `{ app: '<why>' }` is an application table: its rows belong to no candidate. An endpoint declared
|
|
272
|
+
`access: { action, app: true }` reads and writes it through `ctx.db.app`, with the action the operation names. An
|
|
273
|
+
endpoint `on`, `in` or `over` a candidate reads it, read-only and every row, where its action is the table's read
|
|
274
|
+
action: `decide` reads `reasons` with `candidates.review`. A position or a reason is deactivated, never deleted, so
|
|
275
|
+
neither table has a delete action. `candidate_exports` is appended with the export's own action, `candidates.export`,
|
|
276
|
+
and read with `exports.read`.
|
|
277
|
+
- The form writes each input field to the column of the same name: `full_name`, never `fullName`. tsc refuses another
|
|
278
|
+
name at the form's `appendTo`, and the checker refuses it too. A `ctx.sql` row still reads it as `fullName`.
|
|
279
|
+
- The form writes nothing else, so an inbox records the time of a submission only in a `DEFAULT now()` column
|
|
280
|
+
(`received_at`). The job's own `now()` is later: seconds after a kick, up to a minute when the schedule recovers it.
|
|
281
|
+
An inbox's `id` is a `DEFAULT gen_random_uuid()` for the same reason: the job takes and counts each row by it.
|
|
282
|
+
- The delivery columns are read only with `candidates.process`, so neither HR nor the link's holder sees them.
|
|
283
|
+
|
|
284
|
+
## The contract
|
|
285
|
+
|
|
286
|
+
```ts src/schemas.ts
|
|
287
|
+
import { z } from 'zod/v4'
|
|
288
|
+
|
|
289
|
+
export const CandidateStatus = z.enum([
|
|
290
|
+
'new',
|
|
291
|
+
'screening',
|
|
292
|
+
'interview',
|
|
293
|
+
'hired',
|
|
294
|
+
'rejected',
|
|
295
|
+
])
|
|
296
|
+
export const Register = z.strictObject({
|
|
297
|
+
email: z.email().max(200),
|
|
298
|
+
full_name: z.string().min(1).max(200),
|
|
299
|
+
website: z.optional(z.string().max(200)),
|
|
300
|
+
challengeToken: z.string().max(4096),
|
|
301
|
+
})
|
|
302
|
+
export const Resend = z.strictObject({ email: z.email().max(200) })
|
|
303
|
+
export const Me = z.strictObject({
|
|
304
|
+
name: z.string(),
|
|
305
|
+
email: z.string(),
|
|
306
|
+
verified: z.boolean(),
|
|
307
|
+
})
|
|
308
|
+
export const Verified = z.strictObject({ verifiedAt: z.iso.datetime() })
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
```ts fabrika.contract.ts
|
|
312
|
+
import { app } from './src/app-kit'
|
|
313
|
+
import { candidates } from './src/candidates/endpoints'
|
|
314
|
+
import { intake } from './src/intake/endpoints'
|
|
315
|
+
import { portal } from './src/portal/endpoints'
|
|
316
|
+
import { positions } from './src/positions/endpoints'
|
|
317
|
+
import { reasons } from './src/reasons/endpoints'
|
|
318
|
+
import { Register, Resend } from './src/schemas'
|
|
319
|
+
import { settings } from './src/settings/endpoints'
|
|
320
|
+
|
|
321
|
+
export const contract = app.contract((c) => ({
|
|
322
|
+
site: c.spa('/', { dir: 'dist/site', access: { public: true } }),
|
|
323
|
+
register: c.form('/register', {
|
|
324
|
+
input: Register,
|
|
325
|
+
appendTo: 'registrations',
|
|
326
|
+
maxBytes: 4096,
|
|
327
|
+
honeypot: 'website',
|
|
328
|
+
challenge: {
|
|
329
|
+
provider: 'turnstile',
|
|
330
|
+
secret: 'TURNSTILE_SECRET',
|
|
331
|
+
siteKey: 'TURNSTILE_SITE_KEY',
|
|
332
|
+
},
|
|
333
|
+
then: 'intake',
|
|
334
|
+
}),
|
|
335
|
+
resend: c.form('/resend', {
|
|
336
|
+
input: Resend,
|
|
337
|
+
appendTo: 'resend_requests',
|
|
338
|
+
maxBytes: 1024,
|
|
339
|
+
then: 'intake',
|
|
340
|
+
}),
|
|
341
|
+
portal,
|
|
342
|
+
api: c.rpc('/api', { candidates, positions, reasons, settings }),
|
|
343
|
+
hr: c.spa('/hr', {
|
|
344
|
+
dir: 'dist/hr',
|
|
345
|
+
access: { action: 'candidates.read', over: 'candidate' },
|
|
346
|
+
}),
|
|
347
|
+
files: {
|
|
348
|
+
attachments: c.files({
|
|
349
|
+
owner: 'candidates',
|
|
350
|
+
read: 'candidates.read',
|
|
351
|
+
write: 'candidates.attach',
|
|
352
|
+
delete: 'candidates.review',
|
|
353
|
+
maxBytes: 10485760,
|
|
354
|
+
types: ['application/pdf'],
|
|
355
|
+
maxPerOwner: 5,
|
|
356
|
+
link: true,
|
|
357
|
+
scan: true,
|
|
358
|
+
}),
|
|
359
|
+
},
|
|
360
|
+
jobs: { intake },
|
|
361
|
+
health: c.health('/healthz'),
|
|
362
|
+
}))
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
- **The public page.** `access: { public: true }` serves the SPA to anyone, without a token, and takes no other key.
|
|
366
|
+
At `/` it answers every `GET` that no more specific endpoint serves, so the HR SPA (`/hr`) and the link's SPA (`/p`)
|
|
367
|
+
keep their own. The approver sees it as "for anyone, without signing in". Put nothing on it that only members may
|
|
368
|
+
read: the page calls no procedure.
|
|
369
|
+
- **The form's abuse control.** `honeypot` names an optional string field the page hides: a filled one gets the same
|
|
370
|
+
answer and writes no row. `challenge` makes the framework verify the Turnstile token in `challengeToken` before it
|
|
371
|
+
writes the row (a failed token 400, an unreachable provider 503, no row either way) and adds Turnstile's origin to
|
|
372
|
+
the CSP. `TURNSTILE_SECRET` is an application variable the human sets; without it the application does not boot.
|
|
373
|
+
`siteKey` names the variable with the widget's public site key, which every SPA mount serves its pages; without it
|
|
374
|
+
the application does not boot either. Another public value a page needs per environment (a link target) goes in
|
|
375
|
+
the SPA mount's own list: `c.spa('/', { …, public: ['SIGNUP_URL'] })`. Both reach the page in
|
|
376
|
+
`<prefix>/__fabrika-config.json` as `public: { <VARIABLE>: <value> }`, and the approver sees each name. Never name
|
|
377
|
+
a secret there: the contract refuses a variable that is both.
|
|
378
|
+
`rate: { perClient, perForm }` (per minute, 10 and 100 by default) answers 429 past either limit.
|
|
379
|
+
- **Zerops shares one client address.** Until the platform reads the visitor's address behind the Zerops balancer,
|
|
380
|
+
every visitor counts as one client there, so `perClient` acts as a second per-form limit: set it to the form's
|
|
381
|
+
expected peak, never lower it to stop a flood. The challenge is the defense that does not depend on the address.
|
|
382
|
+
- **Counting clicks** on a public page has no primitive. The pattern is an anonymous form without a challenge whose
|
|
383
|
+
inbox gets one row per click, posted with `fetch(…, { keepalive: true })`. Know both limits before you offer it:
|
|
384
|
+
anyone can inflate the counts (the rate limits bound only how fast), and nothing drains the inbox but a job you
|
|
385
|
+
write. Ask the human first; usually the application should not count clicks.
|
|
386
|
+
- **Other origins** the pages load (an image host, an API the browser calls) go in the contract's `csp` section:
|
|
387
|
+
`csp: { scriptSrc: [{ origin: 'https://…', purpose: '…' }] }`, beside `connectSrc`, `imgSrc`, `frameSrc`,
|
|
388
|
+
`styleSrc`, `fontSrc` and `frameAncestors`: exact `https://` origins, at most 16 each. Each new origin is a widening the approver sees.
|
|
389
|
+
- **Scanned files.** A visitor's upload waits `quarantined` until ClamAV answers clean (`references/files.md`): its
|
|
390
|
+
download answers 409 until then. On Zerops the deploy service needs `verticalAutoscaling: { minRam: 2 }`.
|
|
391
|
+
- **The kick.** `then: 'intake'` makes each accepted submission start a run of the job within seconds, once its row
|
|
392
|
+
committed, under the job's own key. The visitor's answer is the same constant and never waits for the run. Kicks
|
|
393
|
+
coalesce on the job's lease (at most one run and one pending, however many visitors submit), so a flood adds no
|
|
394
|
+
work: the run still drains the inbox in batches of 50, within each address's budget. A kick carries nothing; the
|
|
395
|
+
job reads its inbox.
|
|
396
|
+
|
|
397
|
+
## The job: inbox → record → link → e-mail
|
|
398
|
+
|
|
399
|
+
```ts src/mail.ts
|
|
400
|
+
import { defineDeps } from '@fabrika/app/implement'
|
|
401
|
+
|
|
402
|
+
export interface Mail {
|
|
403
|
+
readonly to: string
|
|
404
|
+
readonly url: string
|
|
405
|
+
/** The provider sends one message per key, however often the job hands it over. */
|
|
406
|
+
readonly idempotencyKey: string
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export interface Mailer {
|
|
410
|
+
/** Sends the link; throws when the provider refuses, and gives up once `signal` aborts. */
|
|
411
|
+
sendLink(mail: Mail, signal: AbortSignal): Promise<void>
|
|
412
|
+
/** Sends a fixed test message to `to`, the same way. */
|
|
413
|
+
sendTest(to: string, signal: AbortSignal): Promise<void>
|
|
414
|
+
/** Seals a link's URL for the row that waits to e-mail it; `open` reads it back. */
|
|
415
|
+
seal(url: string): Promise<string>
|
|
416
|
+
open(sealed: string): Promise<string>
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
interface Message {
|
|
420
|
+
readonly to: string
|
|
421
|
+
readonly subject: string
|
|
422
|
+
readonly text: string
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Read when a mail is sent: the generated tests boot the application without its variables. */
|
|
426
|
+
const variable = (env: Readonly<Record<string, unknown>>, name: string) => {
|
|
427
|
+
const value = env[name]
|
|
428
|
+
if (typeof value !== 'string' || value === '') {
|
|
429
|
+
throw new Error(`the application variable ${name} is not set`)
|
|
430
|
+
}
|
|
431
|
+
return value
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
export const mailer = defineDeps((env): Mailer => {
|
|
435
|
+
// LINK_SEAL_KEY is 32 random bytes in base64: `openssl rand -base64 32`.
|
|
436
|
+
const sealKey = () =>
|
|
437
|
+
crypto.subtle.importKey(
|
|
438
|
+
'raw',
|
|
439
|
+
Buffer.from(variable(env, 'LINK_SEAL_KEY'), 'base64'),
|
|
440
|
+
'AES-GCM',
|
|
441
|
+
false,
|
|
442
|
+
['encrypt', 'decrypt'],
|
|
443
|
+
)
|
|
444
|
+
const send = async (
|
|
445
|
+
message: Message,
|
|
446
|
+
idempotencyKey: string | null,
|
|
447
|
+
signal: AbortSignal,
|
|
448
|
+
) => {
|
|
449
|
+
const response = await fetch(variable(env, 'MAIL_API_URL'), {
|
|
450
|
+
method: 'POST',
|
|
451
|
+
headers: {
|
|
452
|
+
Authorization: `Bearer ${variable(env, 'MAIL_API_KEY')}`,
|
|
453
|
+
'Content-Type': 'application/json',
|
|
454
|
+
...(idempotencyKey === null ? {} : { 'Idempotency-Key': idempotencyKey }),
|
|
455
|
+
},
|
|
456
|
+
body: JSON.stringify(message),
|
|
457
|
+
signal,
|
|
458
|
+
})
|
|
459
|
+
if (!response.ok) {
|
|
460
|
+
throw new Error(`the mail provider answered ${response.status}`)
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
sendLink: (mail, signal) =>
|
|
465
|
+
send(
|
|
466
|
+
{
|
|
467
|
+
to: mail.to,
|
|
468
|
+
subject: 'Confirm your address',
|
|
469
|
+
text: `Open ${
|
|
470
|
+
variable(env, 'INTAKE_ORIGIN')
|
|
471
|
+
}${mail.url} to confirm your address.`,
|
|
472
|
+
},
|
|
473
|
+
mail.idempotencyKey,
|
|
474
|
+
signal,
|
|
475
|
+
),
|
|
476
|
+
// Each click is a new message: the test e-mail takes no key.
|
|
477
|
+
sendTest: (to, signal) =>
|
|
478
|
+
send(
|
|
479
|
+
{
|
|
480
|
+
to,
|
|
481
|
+
subject: 'A test e-mail from the intake',
|
|
482
|
+
text: 'The intake can send you e-mail.',
|
|
483
|
+
},
|
|
484
|
+
null,
|
|
485
|
+
signal,
|
|
486
|
+
),
|
|
487
|
+
async seal(url) {
|
|
488
|
+
const iv = crypto.getRandomValues(new Uint8Array(12))
|
|
489
|
+
const sealed = await crypto.subtle.encrypt(
|
|
490
|
+
{ name: 'AES-GCM', iv },
|
|
491
|
+
await sealKey(),
|
|
492
|
+
new TextEncoder().encode(url),
|
|
493
|
+
)
|
|
494
|
+
return `${Buffer.from(iv).toString('base64')}.${
|
|
495
|
+
Buffer.from(sealed).toString('base64')
|
|
496
|
+
}`
|
|
497
|
+
},
|
|
498
|
+
async open(sealed) {
|
|
499
|
+
const [iv = '', data = ''] = sealed.split('.')
|
|
500
|
+
const url = await crypto.subtle.decrypt(
|
|
501
|
+
{ name: 'AES-GCM', iv: Buffer.from(iv, 'base64') },
|
|
502
|
+
await sealKey(),
|
|
503
|
+
Buffer.from(data, 'base64'),
|
|
504
|
+
)
|
|
505
|
+
return new TextDecoder().decode(url)
|
|
506
|
+
},
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
```ts src/app.ts
|
|
512
|
+
import { serve } from '@fabrika/app/implement'
|
|
513
|
+
import { scoped } from '@fabrika/db'
|
|
514
|
+
import { contract } from '../fabrika.contract'
|
|
515
|
+
import { tables } from './db/tables'
|
|
516
|
+
import { mailer } from './mail'
|
|
517
|
+
|
|
518
|
+
export default serve(contract, { database: scoped(tables), deps: mailer })
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
```ts src/intake/endpoints.ts
|
|
522
|
+
import { z } from 'zod/v4'
|
|
523
|
+
import { app } from '../app-kit'
|
|
524
|
+
import { mailer } from '../mail'
|
|
525
|
+
|
|
526
|
+
const Queued = z.strictObject({
|
|
527
|
+
id: z.string(),
|
|
528
|
+
email: z.string(),
|
|
529
|
+
fullName: z.string(),
|
|
530
|
+
})
|
|
531
|
+
const Ask = z.strictObject({ email: z.string() })
|
|
532
|
+
const Id = z.strictObject({ id: z.string() })
|
|
533
|
+
const Attempts = z.strictObject({ attempts: z.number() })
|
|
534
|
+
const State = z.strictObject({ linkState: z.string() })
|
|
535
|
+
const Due = z.strictObject({
|
|
536
|
+
id: z.string(),
|
|
537
|
+
email: z.string(),
|
|
538
|
+
linkState: z.enum(['due', 'issued']),
|
|
539
|
+
linkId: z.nullable(z.string()),
|
|
540
|
+
linkSeal: z.nullable(z.string()),
|
|
541
|
+
revokeLinkId: z.nullable(z.string()),
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
/** A link lives a week; an address gets at most three e-mails a day, failed deliveries included. */
|
|
545
|
+
const LINK_DAYS = 7
|
|
546
|
+
const SENDS_PER_DAY = 3
|
|
547
|
+
/** The records a run takes of each kind; the next run takes the rest. */
|
|
548
|
+
const BATCH = 50
|
|
549
|
+
/** A record that failed this many attempts is dead: the job leaves it to an operator. */
|
|
550
|
+
const MAX_ATTEMPTS = 5
|
|
551
|
+
/** How long one e-mail may take: far inside the attempt's timeout, so a hanging one is a counted failure. */
|
|
552
|
+
const SEND_TIMEOUT_MS = 10000
|
|
553
|
+
|
|
554
|
+
/** The step of a delivery that failed last: a fixed code, never the error's message. */
|
|
555
|
+
type Step = 'reserve' | 'revoke' | 'issue' | 'send'
|
|
556
|
+
|
|
557
|
+
export const intake = app.cron('* * * * *', {
|
|
558
|
+
actions: [
|
|
559
|
+
'candidates.process',
|
|
560
|
+
'candidates.invite',
|
|
561
|
+
'candidates.read',
|
|
562
|
+
'candidates.verify',
|
|
563
|
+
'candidates.attach',
|
|
564
|
+
],
|
|
565
|
+
scope: 'app',
|
|
566
|
+
retries: 0,
|
|
567
|
+
sql: 'write',
|
|
568
|
+
issues: 'portal',
|
|
569
|
+
})
|
|
570
|
+
.handle(async ({ ctx }) => {
|
|
571
|
+
const mail = mailer.get()
|
|
572
|
+
/**
|
|
573
|
+
* One record's work. A failure is the record's (`failed` counts it) and the run goes on
|
|
574
|
+
* with the next record, unless the attempt itself ended: the next run meets it as it is.
|
|
575
|
+
*/
|
|
576
|
+
const eachRecord = async (
|
|
577
|
+
work: () => Promise<void>,
|
|
578
|
+
failed: () => Promise<void>,
|
|
579
|
+
): Promise<void> => {
|
|
580
|
+
try {
|
|
581
|
+
await work()
|
|
582
|
+
} catch (error) {
|
|
583
|
+
if (ctx.signal.aborted) throw error
|
|
584
|
+
await failed()
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// A registration becomes a candidate once; the form told the visitor nothing either way.
|
|
588
|
+
const queued = await ctx.transaction((tx) =>
|
|
589
|
+
tx.sql(Id)`
|
|
590
|
+
select id from registrations where attempts < ${MAX_ATTEMPTS}
|
|
591
|
+
order by received_at, id limit ${BATCH}`
|
|
592
|
+
)
|
|
593
|
+
for (const { id } of queued) {
|
|
594
|
+
await eachRecord(() =>
|
|
595
|
+
ctx.transaction(async (tx) => {
|
|
596
|
+
// Claimed where it is consumed: a row other work holds is skipped, not waited for.
|
|
597
|
+
const [row] = await tx.sql(Queued)`
|
|
598
|
+
select id, email, full_name from registrations
|
|
599
|
+
where id = ${id} and attempts < ${MAX_ATTEMPTS}
|
|
600
|
+
for update skip locked`
|
|
601
|
+
if (row === undefined) return
|
|
602
|
+
await tx.sql(Id)`
|
|
603
|
+
delete from registrations where id = ${row.id} returning id`
|
|
604
|
+
await tx.sql(Id)`
|
|
605
|
+
insert into candidates (id, email, name, created_by, version)
|
|
606
|
+
values (${crypto.randomUUID()}, ${row.email}, ${row.fullName},
|
|
607
|
+
${ctx.principal.id}, 1)
|
|
608
|
+
on conflict (email) do nothing
|
|
609
|
+
returning id`
|
|
610
|
+
}), async () => {
|
|
611
|
+
const [counted] = await ctx.transaction((tx) =>
|
|
612
|
+
tx.sql(Attempts)`
|
|
613
|
+
update registrations
|
|
614
|
+
set attempts = attempts + 1, last_failure = 'register'
|
|
615
|
+
where id = ${id} returning attempts`
|
|
616
|
+
)
|
|
617
|
+
if (counted !== undefined && counted.attempts >= MAX_ATTEMPTS) {
|
|
618
|
+
console.error(`intake: registration ${id} is dead; an operator decides`)
|
|
619
|
+
}
|
|
620
|
+
})
|
|
621
|
+
}
|
|
622
|
+
// A resend supersedes the link of an unverified candidate, sent or dead, within its budget.
|
|
623
|
+
await ctx.transaction(async (tx) => {
|
|
624
|
+
const asks = await tx.sql(Ask)`
|
|
625
|
+
delete from resend_requests where id in (
|
|
626
|
+
select id from resend_requests order by received_at, id
|
|
627
|
+
limit ${BATCH} for update skip locked)
|
|
628
|
+
returning email`
|
|
629
|
+
for (const ask of asks) {
|
|
630
|
+
await tx.sql(Id)`
|
|
631
|
+
update candidates
|
|
632
|
+
set link_state = 'due', link_id = null, link_seal = null, attempts = 0,
|
|
633
|
+
revoke_link_id = coalesce(link_id, revoke_link_id)
|
|
634
|
+
where email = ${ask.email} and verified_at is null
|
|
635
|
+
and link_state in ('sent', 'failed')
|
|
636
|
+
and (sends < ${SENDS_PER_DAY} or window_started_at < now() - interval '1 day')
|
|
637
|
+
returning id`
|
|
638
|
+
}
|
|
639
|
+
})
|
|
640
|
+
// Only a row whose budget has an e-mail left: spent rows would fill the batch and starve the rest.
|
|
641
|
+
const due = await ctx.transaction((tx) =>
|
|
642
|
+
tx.sql(Due)`
|
|
643
|
+
select id, email, link_state, link_id, link_seal, revoke_link_id
|
|
644
|
+
from candidates
|
|
645
|
+
where link_state in ('due', 'issued')
|
|
646
|
+
and (sends < ${SENDS_PER_DAY} or window_started_at < now() - interval '1 day')
|
|
647
|
+
order by id limit ${BATCH}`
|
|
648
|
+
)
|
|
649
|
+
for (const candidate of due) {
|
|
650
|
+
let step: Step = 'reserve'
|
|
651
|
+
await eachRecord(async () => {
|
|
652
|
+
// The e-mail is charged before any effect; a row other work holds waits for a later run.
|
|
653
|
+
const reserved = await ctx.transaction((tx) =>
|
|
654
|
+
tx.sql(Id)`
|
|
655
|
+
update candidates set
|
|
656
|
+
sends = case when window_started_at < now() - interval '1 day' then 1 else sends + 1 end,
|
|
657
|
+
window_started_at = case when window_started_at < now() - interval '1 day'
|
|
658
|
+
then now() else window_started_at end
|
|
659
|
+
where id = (
|
|
660
|
+
select id from candidates where id = ${candidate.id}
|
|
661
|
+
and (sends < ${SENDS_PER_DAY} or window_started_at < now() - interval '1 day')
|
|
662
|
+
for update skip locked)
|
|
663
|
+
returning id`
|
|
664
|
+
)
|
|
665
|
+
if (reserved.length === 0) return
|
|
666
|
+
// An issued link may already have been e-mailed: it goes out again, never replaced.
|
|
667
|
+
const issuedBefore = candidate.linkState === 'issued'
|
|
668
|
+
&& candidate.linkId !== null && candidate.linkSeal !== null
|
|
669
|
+
? { id: candidate.linkId, seal: candidate.linkSeal }
|
|
670
|
+
: null
|
|
671
|
+
const superseded = candidate.revokeLinkId
|
|
672
|
+
?? (issuedBefore === null ? candidate.linkId : null)
|
|
673
|
+
if (superseded !== null) {
|
|
674
|
+
step = 'revoke'
|
|
675
|
+
ctx.signal.throwIfAborted()
|
|
676
|
+
await ctx.links.revoke(superseded)
|
|
677
|
+
await ctx.transaction((tx) =>
|
|
678
|
+
tx.sql(Id)`
|
|
679
|
+
update candidates set revoke_link_id = null
|
|
680
|
+
where id = ${candidate.id} returning id`
|
|
681
|
+
)
|
|
682
|
+
}
|
|
683
|
+
let link: { readonly id: string; readonly url: string }
|
|
684
|
+
if (issuedBefore !== null) {
|
|
685
|
+
link = { id: issuedBefore.id, url: await mail.open(issuedBefore.seal) }
|
|
686
|
+
} else {
|
|
687
|
+
step = 'issue'
|
|
688
|
+
ctx.signal.throwIfAborted()
|
|
689
|
+
const ref = await ctx.ref('candidates.invite', 'candidate', candidate.id)
|
|
690
|
+
const issued = await ctx.links.issue(ref, {
|
|
691
|
+
mount: 'portal',
|
|
692
|
+
days: LINK_DAYS,
|
|
693
|
+
})
|
|
694
|
+
const sealed = await mail.seal(issued.url)
|
|
695
|
+
await ctx.transaction((tx) =>
|
|
696
|
+
tx.sql(Id)`
|
|
697
|
+
update candidates
|
|
698
|
+
set link_state = 'issued', link_id = ${issued.id}, link_seal = ${sealed}
|
|
699
|
+
where id = ${candidate.id} returning id`
|
|
700
|
+
)
|
|
701
|
+
link = issued
|
|
702
|
+
}
|
|
703
|
+
step = 'send'
|
|
704
|
+
ctx.signal.throwIfAborted()
|
|
705
|
+
await mail.sendLink({
|
|
706
|
+
to: candidate.email,
|
|
707
|
+
url: link.url,
|
|
708
|
+
idempotencyKey: `intake-link-${link.id}`,
|
|
709
|
+
}, AbortSignal.any([ctx.signal, AbortSignal.timeout(SEND_TIMEOUT_MS)]))
|
|
710
|
+
await ctx.transaction((tx) =>
|
|
711
|
+
tx.sql(Id)`
|
|
712
|
+
update candidates
|
|
713
|
+
set link_state = 'sent', link_seal = null, attempts = 0, last_failure = null
|
|
714
|
+
where id = ${candidate.id} and link_id = ${link.id} returning id`
|
|
715
|
+
)
|
|
716
|
+
}, async () => {
|
|
717
|
+
const [counted] = await ctx.transaction((tx) =>
|
|
718
|
+
tx.sql(State)`
|
|
719
|
+
update candidates
|
|
720
|
+
set attempts = attempts + 1, last_failure = ${step},
|
|
721
|
+
link_state = case when attempts + 1 >= ${MAX_ATTEMPTS}
|
|
722
|
+
then 'failed' else link_state end,
|
|
723
|
+
link_seal = case when attempts + 1 >= ${MAX_ATTEMPTS}
|
|
724
|
+
then null else link_seal end
|
|
725
|
+
where id = ${candidate.id} returning link_state`
|
|
726
|
+
)
|
|
727
|
+
if (counted?.linkState === 'failed') {
|
|
728
|
+
console.error(
|
|
729
|
+
`intake: the delivery to ${candidate.id} is dead; an operator decides`,
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
})
|
|
733
|
+
}
|
|
734
|
+
})
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
The job keeps six invariants. Keep them when you change it:
|
|
738
|
+
|
|
739
|
+
- **Each record is its own work, in bounded batches.** A run takes at most 50 registrations, oldest first, and 50 due
|
|
740
|
+
deliveries; the next run takes the rest. Each record's work runs in `eachRecord`: a failure (a row the insert refuses,
|
|
741
|
+
a refused recipient, an e-mail past `SEND_TIMEOUT_MS`, IAM down) adds one to the record's `attempts`, keeps the step
|
|
742
|
+
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
|
|
743
|
+
dead: a registration stays in its inbox and the batch skips it, a delivery is `failed`; the job logs each once, by
|
|
744
|
+
id, never by address. An operator decides: fix the row, or delete it. A resend revives a `failed` delivery, within
|
|
745
|
+
the budget. A failure of an ended attempt (`ctx.signal` aborted) is the attempt's, not the record's: it is thrown,
|
|
746
|
+
and the next run meets the record as it is. So each e-mail gets its own deadline inside the attempt's
|
|
747
|
+
(`AbortSignal.any([ctx.signal, AbortSignal.timeout(SEND_TIMEOUT_MS)])`): a recipient whose e-mail hangs fails its
|
|
748
|
+
own attempt and reaches the dead state, and every later record of the run still goes.
|
|
749
|
+
- **A record is claimed where it is consumed.** Each registration is read again `for update skip locked` in the
|
|
750
|
+
transaction that deletes it and inserts the candidate, the resend batch is `for update skip locked` in the delete that
|
|
751
|
+
takes it, and the reservation skips a candidate row other work holds. A row someone else locks (an operator's
|
|
752
|
+
session, an HR request) waits for a later run and blocks nothing behind it.
|
|
753
|
+
- **The budget is charged first.** Each delivery reserves one e-mail of the address's daily budget, in its own
|
|
754
|
+
transaction, before any effect. A delivery that fails after the e-mail left is still counted, so a provider that
|
|
755
|
+
times out cannot make the job mail one address without end. Only a row with an e-mail left is selected for the batch
|
|
756
|
+
of 50, so spent rows never crowd out the others; the reservation checks the budget again in its own transaction.
|
|
757
|
+
- **Each step is recorded before the next effect.** Revoke a superseded link, then record it; issue, then record the
|
|
758
|
+
link's id and its sealed URL (`issued`); send, then record `sent`. A run that stops anywhere (a crash, a refused
|
|
759
|
+
e-mail, IAM down) is finished by the next run its budget allows.
|
|
760
|
+
- **The link is the delivery's identity.** Its e-mail carries `Idempotency-Key: intake-link-<link id>`. Until the
|
|
761
|
+
e-mail is known sent, the row keeps the link's URL in `link_seal`, sealed with AES-GCM under `LINK_SEAL_KEY`, never
|
|
762
|
+
in plain text, and cleared once it is `sent` or dead. An `issued` row is sent again from it: the same message under
|
|
763
|
+
the same key, which the provider sends once, so an answer or a commit lost after the provider took the e-mail
|
|
764
|
+
sends no second one, and the link the visitor got still works. Only a resend replaces a link. IAM's `issue` takes
|
|
765
|
+
no key: a run that stops between the issue and its record leaves a link no row names, whose key only the job saw
|
|
766
|
+
and which expires with its `days`.
|
|
767
|
+
- **An effect starts only while the attempt is live.** `ctx.signal.throwIfAborted()` runs before each IAM call and the
|
|
768
|
+
e-mail, and the e-mail takes `ctx.signal`. The runner aborts a timed-out attempt before the next one starts, so no
|
|
769
|
+
effect of a stale attempt follows the next one's. `ctx.links` itself refuses to call IAM for an ended attempt.
|
|
770
|
+
|
|
771
|
+
More on the job:
|
|
772
|
+
|
|
773
|
+
- **`issues: 'portal'`** gives the job `ctx.links.issue(ref, { mount, days })` and `ctx.links.revoke(id)` for the
|
|
774
|
+
links it issued. Its `actions` must hold the mount's `issuedBy` and every grant of the mount, or the contract is
|
|
775
|
+
refused: IAM lets an issuer delegate only what it holds. The link is bound to one candidate (the `Ref`), and the
|
|
776
|
+
approver sees the job's `issues` marked "needs closer review".
|
|
777
|
+
- **`sql: 'write'`** is the declared reach into the inboxes: `delete … where id = …` takes each row exactly once, and
|
|
778
|
+
a refused insert rolls its delete back with it. **A job writes no scope fragment**: it is app-wide (`scope: 'app'`
|
|
779
|
+
is the only scope), so `tx.sql.scope` of its `Within` is every row, and an inbox has no scope at all.
|
|
780
|
+
- **The latency.** The forms' kick starts a run within seconds of a submission, so the e-mail leaves seconds later,
|
|
781
|
+
while the address has an e-mail of its daily budget left; a spent one waits until a day after the window's first
|
|
782
|
+
e-mail. Keep the schedule (`* * * * *`): it is the recovery path. A kicked run that dies (a crash, a deploy, a
|
|
783
|
+
timeout) leaves its rows, and the next scheduled run finishes them, within a minute. `retries: 0`: that run is the
|
|
784
|
+
retry. A failed run leaves a pending kick to the schedule too.
|
|
785
|
+
- **A revoked link** still works until the token the proxy holds for it expires, at most 5 minutes. Revocation is the
|
|
786
|
+
stop at IAM; it is not instant.
|
|
787
|
+
- The e-mail goes through `deps` (`src/mail.ts`), built from application variables the human sets (`INTAKE_ORIGIN`,
|
|
788
|
+
`MAIL_API_URL`, `MAIL_API_KEY`, and `LINK_SEAL_KEY`: `openssl rand -base64 32`). A new `LINK_SEAL_KEY` makes the
|
|
789
|
+
rows waiting with a sealed link fail their `open` until they are dead; a resend gives each a new link. `link.url` is `/p/#<key>`: the key is in the fragment, which no server and no
|
|
790
|
+
`Referer` sees.
|
|
791
|
+
|
|
792
|
+
## The visitor's link: verify once, attach files
|
|
793
|
+
|
|
794
|
+
```ts src/portal/endpoints.ts
|
|
795
|
+
import { app } from '../app-kit'
|
|
796
|
+
import { Me, Verified } from '../schemas'
|
|
797
|
+
|
|
798
|
+
export const portal = app.link('/p', {
|
|
799
|
+
spa: 'dist/portal',
|
|
800
|
+
dimension: 'candidate',
|
|
801
|
+
grants: ['candidates.read', 'candidates.verify', 'candidates.attach'],
|
|
802
|
+
issuedBy: 'candidates.invite',
|
|
803
|
+
maxDays: 7,
|
|
804
|
+
procedures: (l) => ({
|
|
805
|
+
me: l.query({ output: Me, access: { action: 'candidates.read' } })
|
|
806
|
+
.handle(async ({ ctx }) => {
|
|
807
|
+
const row = await ctx.db.target.get()
|
|
808
|
+
return {
|
|
809
|
+
name: row.name,
|
|
810
|
+
email: row.email,
|
|
811
|
+
verified: row.verified_at !== null,
|
|
812
|
+
}
|
|
813
|
+
}),
|
|
814
|
+
verify: l.mutation({
|
|
815
|
+
output: Verified,
|
|
816
|
+
access: { action: 'candidates.verify' },
|
|
817
|
+
})
|
|
818
|
+
.handle(async ({ ctx }) => {
|
|
819
|
+
const row = await ctx.db.target.getForUpdate()
|
|
820
|
+
if (row.verified_at !== null) {
|
|
821
|
+
return { verifiedAt: row.verified_at.toISOString() }
|
|
822
|
+
}
|
|
823
|
+
const now = new Date()
|
|
824
|
+
await ctx.db.target.update({ verified_at: now }, {
|
|
825
|
+
expectVersion: row.version,
|
|
826
|
+
})
|
|
827
|
+
return { verifiedAt: now.toISOString() }
|
|
828
|
+
}),
|
|
829
|
+
}),
|
|
830
|
+
})
|
|
831
|
+
```
|
|
832
|
+
|
|
833
|
+
- **Single use is the application's state.** `verify` sets `verified_at` once, under `getForUpdate`; a second call
|
|
834
|
+
answers the same time. IAM counts no uses: the proxy honors a token it holds without asking IAM.
|
|
835
|
+
- A link procedure acts on the link's own object: no `from`, no id in the input. A key of another candidate reaches
|
|
836
|
+
only that candidate's record, and a key whose candidate is gone answers 404.
|
|
837
|
+
- `link: true` on the collection gives the holder's client `portal.files.attachments`: `upload(file)`, `list()` and
|
|
838
|
+
`download(id)`, for the link's candidate only: another candidate's file id answers 404. The mount must grant the
|
|
839
|
+
collection's `write` and `read`. HR reaches the same files through `api.files.attachments`, which names the owner,
|
|
840
|
+
with `candidates.read`. A second link mount on candidates that must not reach the files (a one-click answer) needs
|
|
841
|
+
`link: ['portal']` instead.
|
|
842
|
+
|
|
843
|
+
```ts web/portal/main.ts
|
|
844
|
+
import { createClient, RpcError, shareKey } from '@fabrika/app/client'
|
|
845
|
+
import { contract } from '../../fabrika.contract'
|
|
846
|
+
|
|
847
|
+
const key = shareKey()
|
|
848
|
+
const portal = key === null ? null : createClient(contract, 'portal', { key })
|
|
849
|
+
|
|
850
|
+
export const confirm = async (status: HTMLElement): Promise<void> => {
|
|
851
|
+
if (portal === null) {
|
|
852
|
+
status.textContent = 'This link holds no key.'
|
|
853
|
+
return
|
|
854
|
+
}
|
|
855
|
+
try {
|
|
856
|
+
const me = await portal.me()
|
|
857
|
+
await portal.verify()
|
|
858
|
+
status.textContent = `${me.name}, your address is confirmed.`
|
|
859
|
+
} catch (error) {
|
|
860
|
+
status.textContent = error instanceof RpcError
|
|
861
|
+
? 'This link no longer works: ask for a new one.'
|
|
862
|
+
: 'Failed'
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export const attach = async (file: File): Promise<string> => {
|
|
867
|
+
if (portal === null) return 'This link holds no key.'
|
|
868
|
+
try {
|
|
869
|
+
await portal.files.attachments.upload(file)
|
|
870
|
+
return 'Received: it is being checked.'
|
|
871
|
+
} catch (error) {
|
|
872
|
+
return error instanceof RpcError ? `Refused: ${error.type}` : 'Failed'
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
The public page reads its site key from the config the framework serves it, so one build serves every environment.
|
|
878
|
+
|
|
879
|
+
```ts web/site/config.ts
|
|
880
|
+
import { z } from 'zod/v4'
|
|
881
|
+
|
|
882
|
+
const SiteConfig = z.object({
|
|
883
|
+
public: z.object({ TURNSTILE_SITE_KEY: z.string() }),
|
|
884
|
+
})
|
|
885
|
+
|
|
886
|
+
/** The Turnstile widget's site key, or `null` when the config is unreadable. */
|
|
887
|
+
export const siteKey = async (): Promise<string | null> => {
|
|
888
|
+
const response = await fetch('/__fabrika-config.json')
|
|
889
|
+
const config = SiteConfig.safeParse(response.ok ? await response.json() : null)
|
|
890
|
+
return config.success ? config.data.public.TURNSTILE_SITE_KEY : null
|
|
891
|
+
}
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
The public page sends the form with a plain `fetch`: a form has no typed client, and its answer never depends on the
|
|
895
|
+
data.
|
|
896
|
+
|
|
897
|
+
```ts web/site/register.ts
|
|
898
|
+
export const register = async (
|
|
899
|
+
form: HTMLFormElement,
|
|
900
|
+
challengeToken: string,
|
|
901
|
+
): Promise<boolean> => {
|
|
902
|
+
const data = new FormData(form)
|
|
903
|
+
const field = (name: string) => {
|
|
904
|
+
const value = data.get(name)
|
|
905
|
+
return typeof value === 'string' ? value : ''
|
|
906
|
+
}
|
|
907
|
+
const response = await fetch('/register', {
|
|
908
|
+
method: 'POST',
|
|
909
|
+
headers: { 'Content-Type': 'application/json' },
|
|
910
|
+
body: JSON.stringify({
|
|
911
|
+
email: field('email'),
|
|
912
|
+
full_name: field('full_name'),
|
|
913
|
+
website: field('website'),
|
|
914
|
+
challengeToken,
|
|
915
|
+
}),
|
|
916
|
+
})
|
|
917
|
+
return response.ok
|
|
918
|
+
}
|
|
919
|
+
```
|
|
920
|
+
|
|
921
|
+
- The page renders the Turnstile widget with the site key `siteKey()` reads and `action` set to the form's mount name
|
|
922
|
+
(`register`), and passes the widget's token. Say "check your inbox" on success whatever the address was.
|
|
923
|
+
- The resend form is the same call to `/resend` with `{ email }`. It answers the same for an unknown, a verified and a
|
|
924
|
+
rate-limited address; the job decides.
|
|
925
|
+
- Build each SPA into its `dir` (`dist/site`, `dist/portal`, `dist/hr`): one `Bun.build` per `index.html` in
|
|
926
|
+
`build-web.ts`.
|
|
927
|
+
|
|
928
|
+
## HR: the list, the review and its history
|
|
929
|
+
|
|
930
|
+
```ts src/candidates/logic.ts
|
|
931
|
+
type Status = 'new' | 'screening' | 'interview' | 'hired' | 'rejected'
|
|
932
|
+
|
|
933
|
+
const NEXT: Readonly<Record<Status, readonly Status[]>> = {
|
|
934
|
+
new: ['screening', 'rejected'],
|
|
935
|
+
screening: ['interview', 'rejected'],
|
|
936
|
+
interview: ['hired', 'rejected'],
|
|
937
|
+
hired: [],
|
|
938
|
+
rejected: [],
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
export const canMove = (from: Status, to: Status): boolean =>
|
|
942
|
+
NEXT[from].includes(to)
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The stage HR sees. Two actions set the two columns it derives from: the link holder's `candidates.verify` sets
|
|
946
|
+
* `verified_at`, HR's `candidates.review` sets `status`. A column has one write action, so the state is two columns.
|
|
947
|
+
*/
|
|
948
|
+
export const stageOf = (
|
|
949
|
+
status: Status,
|
|
950
|
+
verifiedAt: Date | null,
|
|
951
|
+
): Status | 'unconfirmed' => (verifiedAt === null ? 'unconfirmed' : status)
|
|
952
|
+
```
|
|
953
|
+
|
|
954
|
+
```ts src/candidates/endpoints.ts
|
|
955
|
+
import { BadRequestError, ConflictError, fromPage, fromRow } from '@fabrika/app'
|
|
956
|
+
import { attachment } from '@fabrika/app/contract'
|
|
957
|
+
import { z } from 'zod/v4'
|
|
958
|
+
import { app } from '../app-kit'
|
|
959
|
+
import { CandidateStatus } from '../schemas'
|
|
960
|
+
import { canMove } from './logic'
|
|
961
|
+
|
|
962
|
+
const Cursor = z.string().max(500)
|
|
963
|
+
const Candidate = z.strictObject({
|
|
964
|
+
id: z.uuid(),
|
|
965
|
+
name: z.string(),
|
|
966
|
+
email: z.string(),
|
|
967
|
+
status: CandidateStatus,
|
|
968
|
+
verifiedAt: z.nullable(z.iso.datetime()),
|
|
969
|
+
version: z.int(),
|
|
970
|
+
})
|
|
971
|
+
const CandidatePage = z.strictObject({
|
|
972
|
+
items: z.array(Candidate),
|
|
973
|
+
next: z.nullable(z.string()),
|
|
974
|
+
})
|
|
975
|
+
const Event = z.strictObject({
|
|
976
|
+
status: CandidateStatus,
|
|
977
|
+
note: z.nullable(z.string()),
|
|
978
|
+
createdBy: z.string(),
|
|
979
|
+
createdAt: z.iso.datetime(),
|
|
980
|
+
})
|
|
981
|
+
const History = z.strictObject({
|
|
982
|
+
items: z.array(Event),
|
|
983
|
+
next: z.nullable(z.string()),
|
|
984
|
+
})
|
|
985
|
+
const ListCandidates = z.strictObject({
|
|
986
|
+
status: CandidateStatus.optional(),
|
|
987
|
+
after: Cursor.optional(),
|
|
988
|
+
})
|
|
989
|
+
const ById = z.strictObject({ id: z.uuid() })
|
|
990
|
+
const HistoryOf = z.strictObject({ id: z.uuid(), after: Cursor.optional() })
|
|
991
|
+
const Decide = z.strictObject({
|
|
992
|
+
id: z.uuid(),
|
|
993
|
+
expectVersion: z.int().positive(),
|
|
994
|
+
status: CandidateStatus,
|
|
995
|
+
note: z.nullable(z.string().max(2000)),
|
|
996
|
+
reasonId: z.nullable(z.uuid()),
|
|
997
|
+
})
|
|
998
|
+
const Saved = z.strictObject({ id: z.uuid(), version: z.int() })
|
|
999
|
+
const ExportRow = z.strictObject({
|
|
1000
|
+
id: z.uuid(),
|
|
1001
|
+
name: z.string(),
|
|
1002
|
+
email: z.string(),
|
|
1003
|
+
status: CandidateStatus,
|
|
1004
|
+
})
|
|
1005
|
+
const CandidateFile = attachment({
|
|
1006
|
+
format: 'csv',
|
|
1007
|
+
filename: 'candidates.csv',
|
|
1008
|
+
row: ExportRow,
|
|
1009
|
+
maxBytes: 4194304,
|
|
1010
|
+
})
|
|
1011
|
+
const ExportEntry = z.strictObject({
|
|
1012
|
+
rowCount: z.int(),
|
|
1013
|
+
createdBy: z.string(),
|
|
1014
|
+
createdAt: z.iso.datetime(),
|
|
1015
|
+
})
|
|
1016
|
+
const ExportLog = z.strictObject({
|
|
1017
|
+
items: z.array(ExportEntry),
|
|
1018
|
+
next: z.nullable(z.string()),
|
|
1019
|
+
})
|
|
1020
|
+
const LogPage = z.strictObject({ after: Cursor.optional() })
|
|
1021
|
+
const Person = z.strictObject({
|
|
1022
|
+
principalId: z.string(),
|
|
1023
|
+
name: z.nullable(z.string()),
|
|
1024
|
+
active: z.boolean(),
|
|
1025
|
+
})
|
|
1026
|
+
const People = z.strictObject({
|
|
1027
|
+
items: z.array(Person),
|
|
1028
|
+
next: z.nullable(z.string()),
|
|
1029
|
+
})
|
|
1030
|
+
|
|
1031
|
+
/** The file's row limit: a larger export is refused, never cut short. */
|
|
1032
|
+
const MAX_EXPORT_ROWS = 10000
|
|
1033
|
+
|
|
1034
|
+
export const candidates = app.group({ callers: ['human'] }, (r) => ({
|
|
1035
|
+
list: r.query({
|
|
1036
|
+
input: ListCandidates,
|
|
1037
|
+
output: CandidatePage,
|
|
1038
|
+
access: { action: 'candidates.read', over: 'candidate' },
|
|
1039
|
+
})
|
|
1040
|
+
.handle(async ({ input, ctx }) =>
|
|
1041
|
+
fromPage(
|
|
1042
|
+
Candidate,
|
|
1043
|
+
await ctx.db.within.candidates.list({
|
|
1044
|
+
where: input.status === undefined
|
|
1045
|
+
? []
|
|
1046
|
+
: [['status', '=', input.status]],
|
|
1047
|
+
orderBy: [['name', 'asc']],
|
|
1048
|
+
after: input.after,
|
|
1049
|
+
}),
|
|
1050
|
+
)
|
|
1051
|
+
),
|
|
1052
|
+
get: r.query({
|
|
1053
|
+
input: ById,
|
|
1054
|
+
output: Candidate,
|
|
1055
|
+
access: { action: 'candidates.read', on: 'candidate', from: 'id' },
|
|
1056
|
+
})
|
|
1057
|
+
.handle(async ({ ctx }) => fromRow(Candidate, await ctx.db.target.get())),
|
|
1058
|
+
history: r.query({
|
|
1059
|
+
input: HistoryOf,
|
|
1060
|
+
output: History,
|
|
1061
|
+
access: { action: 'candidates.read', on: 'candidate', from: 'id' },
|
|
1062
|
+
})
|
|
1063
|
+
.handle(async ({ input, ctx }) =>
|
|
1064
|
+
fromPage(
|
|
1065
|
+
Event,
|
|
1066
|
+
await ctx.db.target.candidate_events.list({
|
|
1067
|
+
orderBy: [['created_at', 'asc']],
|
|
1068
|
+
after: input.after,
|
|
1069
|
+
}),
|
|
1070
|
+
)
|
|
1071
|
+
),
|
|
1072
|
+
decide: r.mutation({
|
|
1073
|
+
input: Decide,
|
|
1074
|
+
output: Saved,
|
|
1075
|
+
access: { action: 'candidates.review', on: 'candidate', from: 'id' },
|
|
1076
|
+
})
|
|
1077
|
+
.handle(async ({ input, ctx }) => {
|
|
1078
|
+
const candidate = await ctx.db.target.getForUpdate()
|
|
1079
|
+
if (!canMove(candidate.status, input.status)) {
|
|
1080
|
+
throw new ConflictError(
|
|
1081
|
+
`a ${candidate.status} candidate does not move to ${input.status}`,
|
|
1082
|
+
)
|
|
1083
|
+
}
|
|
1084
|
+
if (input.reasonId !== null) {
|
|
1085
|
+
const known = await ctx.db.app.reasons.count({
|
|
1086
|
+
where: [['id', '=', input.reasonId], ['active', '=', true]],
|
|
1087
|
+
})
|
|
1088
|
+
if (known === 0) {
|
|
1089
|
+
throw new BadRequestError(
|
|
1090
|
+
`the reason ${input.reasonId} is unknown or retired`,
|
|
1091
|
+
)
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
await ctx.db.target.candidate_events.insert({
|
|
1095
|
+
status: input.status,
|
|
1096
|
+
note: input.note,
|
|
1097
|
+
reason_id: input.reasonId,
|
|
1098
|
+
})
|
|
1099
|
+
return ctx.db.target.update({ status: input.status }, {
|
|
1100
|
+
expectVersion: input.expectVersion,
|
|
1101
|
+
})
|
|
1102
|
+
}),
|
|
1103
|
+
export: r.mutation({
|
|
1104
|
+
output: CandidateFile,
|
|
1105
|
+
access: { action: 'candidates.export', app: true },
|
|
1106
|
+
})
|
|
1107
|
+
.handle(async ({ ctx }) => {
|
|
1108
|
+
const rows: z.output<typeof ExportRow>[] = []
|
|
1109
|
+
let after: string | undefined
|
|
1110
|
+
do {
|
|
1111
|
+
const page = await ctx.db.app.candidates.list({
|
|
1112
|
+
orderBy: [['name', 'asc']],
|
|
1113
|
+
limit: 500,
|
|
1114
|
+
after,
|
|
1115
|
+
})
|
|
1116
|
+
rows.push(...page.items.map((row) => fromRow(ExportRow, row)))
|
|
1117
|
+
after = page.next ?? undefined
|
|
1118
|
+
} while (after !== undefined && rows.length <= MAX_EXPORT_ROWS)
|
|
1119
|
+
if (rows.length > MAX_EXPORT_ROWS) {
|
|
1120
|
+
throw new ConflictError(
|
|
1121
|
+
`more than ${MAX_EXPORT_ROWS} candidates: export a filtered list`,
|
|
1122
|
+
)
|
|
1123
|
+
}
|
|
1124
|
+
await ctx.db.app.candidate_exports.insert({ row_count: rows.length })
|
|
1125
|
+
return rows
|
|
1126
|
+
}),
|
|
1127
|
+
exports: r.query({
|
|
1128
|
+
input: LogPage,
|
|
1129
|
+
output: ExportLog,
|
|
1130
|
+
access: { action: 'exports.read', app: true },
|
|
1131
|
+
})
|
|
1132
|
+
.handle(async ({ input, ctx }) =>
|
|
1133
|
+
fromPage(
|
|
1134
|
+
ExportEntry,
|
|
1135
|
+
await ctx.db.app.candidate_exports.list({
|
|
1136
|
+
orderBy: [['created_at', 'desc']],
|
|
1137
|
+
after: input.after,
|
|
1138
|
+
}),
|
|
1139
|
+
)
|
|
1140
|
+
),
|
|
1141
|
+
people: r.query({
|
|
1142
|
+
input: LogPage,
|
|
1143
|
+
output: People,
|
|
1144
|
+
access: { action: 'people.list', app: true },
|
|
1145
|
+
})
|
|
1146
|
+
.handle(async ({ input, ctx }) => {
|
|
1147
|
+
const page = await ctx.members.list({ after: input.after })
|
|
1148
|
+
return {
|
|
1149
|
+
items: page.members.map((person) => ({
|
|
1150
|
+
principalId: person.principalId,
|
|
1151
|
+
name: person.label,
|
|
1152
|
+
active: !person.disabled,
|
|
1153
|
+
})),
|
|
1154
|
+
next: page.next,
|
|
1155
|
+
}
|
|
1156
|
+
}),
|
|
1157
|
+
}))
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
- **A code list an administrator edits is validated by reading it.** `decide` runs `on` one candidate with
|
|
1161
|
+
`candidates.review`, the read action of `reasons`, so `ctx.db.app.reasons` reads every reason there, read-only. A
|
|
1162
|
+
reason that is unknown or retired answers 400 that names it, before anything is written. Do not let a foreign key
|
|
1163
|
+
stand in for the check: its failure is a 500 that names no value.
|
|
1164
|
+
- **Application history is a table on the paved road.** `decide` writes its `candidate_events` row through the
|
|
1165
|
+
handle, in the invocation's one transaction: the status and its history row commit together or not at all, and
|
|
1166
|
+
`created_by` is the caller. There is no framework audit API; never write history from a job or after the answer.
|
|
1167
|
+
- **The author of a change is named from the application's directory.** A history row keeps `created_by`, the
|
|
1168
|
+
principal id; `people` answers every person holding a role of the application with their current label, a page of
|
|
1169
|
+
at most 100 at a time, and the HR SPA names each `createdBy` from it. Never copy a label into the row: a person's
|
|
1170
|
+
label changes, and the directory is the one current answer. `ctx.members.list()` exists only on an `app: true`
|
|
1171
|
+
endpoint whose action is the vocabulary's `members.list`, and IAM checks that action live, held globally.
|
|
1172
|
+
- **An audited export of every candidate** is one `app` mutation under an action of its own, `candidates.export`.
|
|
1173
|
+
`ctx.db.app.candidates` lists every candidate, read-only, because the candidates' policy names that action in its
|
|
1174
|
+
`appRead`, and the log row goes into `candidate_exports`, whose insert action is the export's, in the invocation's
|
|
1175
|
+
one transaction. The file is encoded before the commit, so no file leaves without its row
|
|
1176
|
+
(`references/endpoint.md`). It needs no `sql` and no `unscoped`. HR reads every candidate and may not export them:
|
|
1177
|
+
a global holder of only `candidates.read` gets 403 from the export; the HR lead's role grants `candidates.export`.
|
|
1178
|
+
- **`app: true` is for data of the whole application.** The proxy admits the endpoint only for a caller who holds the
|
|
1179
|
+
action as a global grant: an HR member granted `candidates.read` on one candidate gets 403. `list` and `get` stay
|
|
1180
|
+
`over` and `on`, which serve a scoped grant too.
|
|
1181
|
+
- `over: 'candidate'` lists every candidate a caller holds `candidates.read` on. HR holds it globally, so
|
|
1182
|
+
`ctx.within.values` is `'all'`.
|
|
1183
|
+
- The generated case of `decide` sends the first status (`new`) to a record that is `new`, which `canMove` refuses
|
|
1184
|
+
with 409. Give it an input in `src/__tests__/endpoint-access.fixtures.ts`:
|
|
1185
|
+
|
|
1186
|
+
```ts src/__tests__/endpoint-access.fixtures.ts
|
|
1187
|
+
export const fixtures = {
|
|
1188
|
+
'api.candidates.decide': {
|
|
1189
|
+
id: '00000000-0000-4000-8000-000000000000',
|
|
1190
|
+
expectVersion: 1,
|
|
1191
|
+
status: 'screening',
|
|
1192
|
+
note: null,
|
|
1193
|
+
reasonId: null,
|
|
1194
|
+
},
|
|
1195
|
+
}
|
|
1196
|
+
```
|
|
1197
|
+
|
|
1198
|
+
## The code list an HR lead edits
|
|
1199
|
+
|
|
1200
|
+
```ts src/positions/endpoints.ts
|
|
1201
|
+
import { fromPage } from '@fabrika/app'
|
|
1202
|
+
import { z } from 'zod/v4'
|
|
1203
|
+
import { app } from '../app-kit'
|
|
1204
|
+
|
|
1205
|
+
const Position = z.strictObject({
|
|
1206
|
+
id: z.uuid(),
|
|
1207
|
+
title: z.string(),
|
|
1208
|
+
active: z.boolean(),
|
|
1209
|
+
version: z.int(),
|
|
1210
|
+
})
|
|
1211
|
+
const Positions = z.strictObject({
|
|
1212
|
+
items: z.array(Position),
|
|
1213
|
+
next: z.nullable(z.string()),
|
|
1214
|
+
})
|
|
1215
|
+
const Page = z.strictObject({ after: z.string().max(500).optional() })
|
|
1216
|
+
const NewPosition = z.strictObject({ title: z.string().min(1).max(200) })
|
|
1217
|
+
const EditPosition = z.strictObject({
|
|
1218
|
+
id: z.uuid(),
|
|
1219
|
+
expectVersion: z.int().positive(),
|
|
1220
|
+
title: z.string().min(1).max(200),
|
|
1221
|
+
active: z.boolean(),
|
|
1222
|
+
})
|
|
1223
|
+
const Saved = z.strictObject({ id: z.uuid(), version: z.int() })
|
|
1224
|
+
|
|
1225
|
+
export const positions = app.group({ callers: ['human'] }, (r) => ({
|
|
1226
|
+
list: r.query({
|
|
1227
|
+
input: Page,
|
|
1228
|
+
output: Positions,
|
|
1229
|
+
access: { action: 'positions.read', app: true },
|
|
1230
|
+
})
|
|
1231
|
+
.handle(async ({ input, ctx }) =>
|
|
1232
|
+
fromPage(
|
|
1233
|
+
Position,
|
|
1234
|
+
await ctx.db.app.positions.list({
|
|
1235
|
+
orderBy: [['title', 'asc']],
|
|
1236
|
+
after: input.after,
|
|
1237
|
+
}),
|
|
1238
|
+
)
|
|
1239
|
+
),
|
|
1240
|
+
add: r.mutation({
|
|
1241
|
+
input: NewPosition,
|
|
1242
|
+
output: Saved,
|
|
1243
|
+
access: { action: 'positions.manage', app: true },
|
|
1244
|
+
})
|
|
1245
|
+
.handle(({ input, ctx }) =>
|
|
1246
|
+
ctx.db.app.positions.insert({ title: input.title })
|
|
1247
|
+
),
|
|
1248
|
+
edit: r.mutation({
|
|
1249
|
+
input: EditPosition,
|
|
1250
|
+
output: Saved,
|
|
1251
|
+
access: { action: 'positions.manage', app: true },
|
|
1252
|
+
})
|
|
1253
|
+
.handle(({ input, ctx }) =>
|
|
1254
|
+
ctx.db.app.positions.update(input.id, {
|
|
1255
|
+
title: input.title,
|
|
1256
|
+
active: input.active,
|
|
1257
|
+
}, { expectVersion: input.expectVersion })
|
|
1258
|
+
),
|
|
1259
|
+
}))
|
|
1260
|
+
```
|
|
1261
|
+
|
|
1262
|
+
```ts src/reasons/endpoints.ts
|
|
1263
|
+
import { fromPage } from '@fabrika/app'
|
|
1264
|
+
import { z } from 'zod/v4'
|
|
1265
|
+
import { app } from '../app-kit'
|
|
1266
|
+
|
|
1267
|
+
const Reason = z.strictObject({
|
|
1268
|
+
id: z.uuid(),
|
|
1269
|
+
label: z.string(),
|
|
1270
|
+
active: z.boolean(),
|
|
1271
|
+
version: z.int(),
|
|
1272
|
+
})
|
|
1273
|
+
const Reasons = z.strictObject({
|
|
1274
|
+
items: z.array(Reason),
|
|
1275
|
+
next: z.nullable(z.string()),
|
|
1276
|
+
})
|
|
1277
|
+
const Page = z.strictObject({ after: z.string().max(500).optional() })
|
|
1278
|
+
const NewReason = z.strictObject({ label: z.string().min(1).max(200) })
|
|
1279
|
+
const EditReason = z.strictObject({
|
|
1280
|
+
id: z.uuid(),
|
|
1281
|
+
expectVersion: z.int().positive(),
|
|
1282
|
+
label: z.string().min(1).max(200),
|
|
1283
|
+
active: z.boolean(),
|
|
1284
|
+
})
|
|
1285
|
+
const Saved = z.strictObject({ id: z.uuid(), version: z.int() })
|
|
1286
|
+
|
|
1287
|
+
export const reasons = app.group({ callers: ['human'] }, (r) => ({
|
|
1288
|
+
list: r.query({
|
|
1289
|
+
input: Page,
|
|
1290
|
+
output: Reasons,
|
|
1291
|
+
access: { action: 'candidates.review', app: true },
|
|
1292
|
+
})
|
|
1293
|
+
.handle(async ({ input, ctx }) =>
|
|
1294
|
+
fromPage(
|
|
1295
|
+
Reason,
|
|
1296
|
+
await ctx.db.app.reasons.list({
|
|
1297
|
+
orderBy: [['label', 'asc']],
|
|
1298
|
+
after: input.after,
|
|
1299
|
+
}),
|
|
1300
|
+
)
|
|
1301
|
+
),
|
|
1302
|
+
add: r.mutation({
|
|
1303
|
+
input: NewReason,
|
|
1304
|
+
output: Saved,
|
|
1305
|
+
access: { action: 'reasons.manage', app: true },
|
|
1306
|
+
})
|
|
1307
|
+
.handle(({ input, ctx }) =>
|
|
1308
|
+
ctx.db.app.reasons.insert({ label: input.label })
|
|
1309
|
+
),
|
|
1310
|
+
edit: r.mutation({
|
|
1311
|
+
input: EditReason,
|
|
1312
|
+
output: Saved,
|
|
1313
|
+
access: { action: 'reasons.manage', app: true },
|
|
1314
|
+
})
|
|
1315
|
+
.handle(({ input, ctx }) =>
|
|
1316
|
+
ctx.db.app.reasons.update(input.id, {
|
|
1317
|
+
label: input.label,
|
|
1318
|
+
active: input.active,
|
|
1319
|
+
}, { expectVersion: input.expectVersion })
|
|
1320
|
+
),
|
|
1321
|
+
}))
|
|
1322
|
+
```
|
|
1323
|
+
|
|
1324
|
+
- `ctx.db.app.<table>` of an application table has `list`, `count` and `aggregate` with its read action, `get(id)`
|
|
1325
|
+
with any action it names, and in a mutation `getForUpdate(id)`, `insert(v)`, `update(id, v, { expectVersion })` and
|
|
1326
|
+
`remove(id, { expectVersion })` with the operation's action. Its key is generated on insert.
|
|
1327
|
+
- The HR SPA reads the list with `positions.read`; an HR lead's page adds and edits with `positions.manage`. A
|
|
1328
|
+
position that is no longer open is deactivated, so a record that names it keeps its meaning. The reasons work the
|
|
1329
|
+
same way under `reasons.manage`.
|
|
1330
|
+
- The table's read action decides who reads it. An endpoint `on`, `in` or `over` a candidate reads `reasons` (`list`,
|
|
1331
|
+
`count`, `aggregate`, `get(id)`, every row, no write) only where its action is `candidates.review`; with another
|
|
1332
|
+
action `ctx.db.app.reasons` is a type error that names the read action, and a refusal at run time. So the HR lead's
|
|
1333
|
+
list of reasons runs with `candidates.review` too, and the role grants it.
|
|
1334
|
+
- A list no person edits, changed only with the code, is a reference table (`references/table-and-migration.md`).
|
|
1335
|
+
|
|
1336
|
+
## A test e-mail to yourself
|
|
1337
|
+
|
|
1338
|
+
```ts src/settings/endpoints.ts
|
|
1339
|
+
import { z } from 'zod/v4'
|
|
1340
|
+
import { app } from '../app-kit'
|
|
1341
|
+
import { mailer } from '../mail'
|
|
1342
|
+
|
|
1343
|
+
const Sent = z.strictObject({ sentTo: z.string() })
|
|
1344
|
+
|
|
1345
|
+
export const settings = app.group({ callers: ['human'] }, (r) => ({
|
|
1346
|
+
testMail: r.mutation({ output: Sent, access: { member: true } })
|
|
1347
|
+
.handle(async ({ ctx }) => {
|
|
1348
|
+
const to = ctx.principal.email
|
|
1349
|
+
await mailer.get().sendTest(to, ctx.signal)
|
|
1350
|
+
return { sentTo: to }
|
|
1351
|
+
}),
|
|
1352
|
+
}))
|
|
1353
|
+
```
|
|
1354
|
+
|
|
1355
|
+
- **The address is the caller's, from the token.** `ctx.principal.email` is the mailbox IAM recorded for the person
|
|
1356
|
+
(lower-cased), so the input has no address field. IAM recorded it; how far it checked that the person controls it is
|
|
1357
|
+
its login method's (an operator may accept an IdP's unverified address). An input that named one would let any member mail any address
|
|
1358
|
+
past every consent the application asks for. Never take a recipient from the input for "send it to me".
|
|
1359
|
+
- `{ member: true }` admits any member of the application and reaches no data: the endpoint mails only the caller.
|
|
1360
|
+
The generated tests give a `member` endpoint no case, so the test below is yours. That is also why it may call the
|
|
1361
|
+
provider itself: the generated cases boot without the mail variables (`references/endpoint.md`). The call takes
|
|
1362
|
+
`ctx.signal`, so it stops when the person leaves or the request runs past its 10 s.
|
|
1363
|
+
- The page shows the button only to someone signed in: its SPA reads `/hr/__fabrika-me.json` (`references/app-setup.md`).
|
|
1364
|
+
|
|
1365
|
+
## Tests
|
|
1366
|
+
|
|
1367
|
+
- The generated tests cover HR's endpoints, the files and the link's procedures: the endpoint access kit asks each
|
|
1368
|
+
link procedure as a holder of a key bound to a seeded candidate. Each `app` endpoint gets two cases: a holder of
|
|
1369
|
+
its action in one candidate only is 403, and a global holder reaches it. `positions`, `reasons` and
|
|
1370
|
+
`candidate_exports` get a policy probe of their actions, held globally and in one scope, and of a scoped site that
|
|
1371
|
+
reads every row with the read action and none with another. The probe of `candidates` also lists every candidate
|
|
1372
|
+
from an `app` endpoint of `candidates.export`, its `appRead`. An endpoint that `issues` gets the cases of any `on`
|
|
1373
|
+
endpoint: the generated tests' stand-in IAM issues its link (`references/tests.md`).
|
|
1374
|
+
- Test the export's own action beside its refused twin: a global holder of `candidates.export` downloads the file, a
|
|
1375
|
+
global holder of only `candidates.read` gets 403.
|
|
1376
|
+
- Your own test of a link procedure makes the holder `kit.testCaller([{ action: 'candidates.verify', scope: { type:
|
|
1377
|
+
'candidate', value: id } }], { kind: 'link', link: { mount: 'portal', chain: [{ type: 'candidate', value: id }] }
|
|
1378
|
+
})` and calls `createClient(contract, 'portal', { fetch: kit.fetch, key: holder.key ?? '' })`.
|
|
1379
|
+
- The job asks IAM to issue and revoke links, and the kit's own IAM answers only the credentials of its callers, not a
|
|
1380
|
+
job's key, so `kit.runJob('intake')` needs `testKit(app, { membership })` with an `IamMembershipRpc` of your own that
|
|
1381
|
+
answers `issueLink` and `revokeLink`. A test of a form passes `env: {
|
|
1382
|
+
TURNSTILE_SECRET: '…', TURNSTILE_SITE_KEY: '…' }` and `challengeFetch`, a stand-in for Turnstile's answer, to `testKit`, and a fresh
|
|
1383
|
+
`challengeToken` with each submission: a token is single use. Test each stop of a delivery (a refused e-mail, IAM
|
|
1384
|
+
down, a crash after each step, an answer lost after the provider took the e-mail): the next run must leave exactly
|
|
1385
|
+
one live, e-mailed link and the provider one message per key. Test a bad record beside good ones too: a registration
|
|
1386
|
+
the insert refuses, a recipient the provider always refuses and one whose e-mail hangs end dead after `MAX_ATTEMPTS`
|
|
1387
|
+
runs, while every other record is delivered and every run succeeds; a registration another session locks waits.
|
|
1388
|
+
- A test that submits a form kicks the job too, and the kicked run may start at once: arm a fault before the
|
|
1389
|
+
submission, never between it and the run. `kit.runJob('intake')` first waits for the kicked runs, then runs once.
|
|
1390
|
+
- The e-mail provider is a stand-in too: `testKit(app, { env: { MAIL_API_URL, MAIL_API_KEY: 'test', INTAKE_ORIGIN:
|
|
1391
|
+
'https://intake.test', LINK_SEAL_KEY } })` points the mailer at a test URL, which `standInMail()` answers, one
|
|
1392
|
+
message per idempotency key as a provider does; a test's `LINK_SEAL_KEY` is `Buffer.alloc(32, 1).toString('base64')`.
|
|
1393
|
+
Call its `restore()` in `afterEach`.
|
|
1394
|
+
- The test e-mail goes to the caller's own address: the test sends no address and reads the recipient from the stand-in.
|
|
1395
|
+
|
|
1396
|
+
```ts src/__tests__/mail-stand-in.ts
|
|
1397
|
+
import { spyOn } from 'bun:test'
|
|
1398
|
+
|
|
1399
|
+
/** The mail provider of a test; `env.MAIL_API_URL` points the mailer here. */
|
|
1400
|
+
export const MAIL_API_URL = 'https://mail.test/send'
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* Answers the mail provider's URL and keeps each mail with its idempotency key, sending one per key as the provider
|
|
1404
|
+
* does; every other request goes on to the real `fetch`.
|
|
1405
|
+
*/
|
|
1406
|
+
export const standInMail = () => {
|
|
1407
|
+
const sent: { readonly key: string | null; readonly body: string }[] = []
|
|
1408
|
+
const passOn = globalThis.fetch
|
|
1409
|
+
const answer = async (...args: Parameters<typeof fetch>) => {
|
|
1410
|
+
const request = new Request(...args)
|
|
1411
|
+
if (request.url !== MAIL_API_URL) return passOn(...args)
|
|
1412
|
+
const key = request.headers.get('Idempotency-Key')
|
|
1413
|
+
if (!sent.some((mail) => mail.key !== null && mail.key === key)) {
|
|
1414
|
+
sent.push({ key, body: await request.text() })
|
|
1415
|
+
}
|
|
1416
|
+
return new Response(null, { status: 202 })
|
|
1417
|
+
}
|
|
1418
|
+
const spy = spyOn(globalThis, 'fetch').mockImplementation(
|
|
1419
|
+
Object.assign(answer, { preconnect: passOn.preconnect }),
|
|
1420
|
+
)
|
|
1421
|
+
return { sent, restore: () => spy.mockRestore() }
|
|
1422
|
+
}
|
|
1423
|
+
```
|
|
1424
|
+
|
|
1425
|
+
```ts src/__tests__/settings.test.ts
|
|
1426
|
+
import { createClient } from '@fabrika/app/client'
|
|
1427
|
+
import { type IamMembershipRpc, testKit } from '@fabrika/app/testing'
|
|
1428
|
+
import { testDatabase } from '@fabrika/db/testing'
|
|
1429
|
+
import { MemoryFileObjects } from '@fabrika/platform/testing'
|
|
1430
|
+
import { afterAll, afterEach, describe, expect, test } from 'bun:test'
|
|
1431
|
+
import { z } from 'zod/v4'
|
|
1432
|
+
import { contract } from '../../fabrika.contract'
|
|
1433
|
+
import app from '../app'
|
|
1434
|
+
import { MAIL_API_URL, standInMail } from './mail-stand-in'
|
|
1435
|
+
|
|
1436
|
+
const postgres = await testDatabase({
|
|
1437
|
+
migrations: new URL('../../db/migrations/', import.meta.url),
|
|
1438
|
+
label: 'settings.test.ts',
|
|
1439
|
+
})
|
|
1440
|
+
|
|
1441
|
+
/** This test issues no link: every membership request is refused. */
|
|
1442
|
+
const refused = (): Promise<{ ok: false; reason: 'not_allowed' }> =>
|
|
1443
|
+
Promise.resolve({ ok: false, reason: 'not_allowed' })
|
|
1444
|
+
const membership: IamMembershipRpc = {
|
|
1445
|
+
requestDelegation: refused,
|
|
1446
|
+
createScope: refused,
|
|
1447
|
+
assignMember: refused,
|
|
1448
|
+
revokeMember: refused,
|
|
1449
|
+
listMembers: refused,
|
|
1450
|
+
listAppMembers: refused,
|
|
1451
|
+
findMember: refused,
|
|
1452
|
+
issueLink: refused,
|
|
1453
|
+
revokeLink: refused,
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
const Mail = z.strictObject({
|
|
1457
|
+
to: z.string(),
|
|
1458
|
+
subject: z.string(),
|
|
1459
|
+
text: z.string(),
|
|
1460
|
+
})
|
|
1461
|
+
|
|
1462
|
+
if (postgres !== null) {
|
|
1463
|
+
afterAll(() => postgres.close())
|
|
1464
|
+
const kit = testKit(app, {
|
|
1465
|
+
database: postgres.database,
|
|
1466
|
+
files: new MemoryFileObjects(),
|
|
1467
|
+
membership,
|
|
1468
|
+
env: {
|
|
1469
|
+
MAIL_API_URL,
|
|
1470
|
+
MAIL_API_KEY: 'test',
|
|
1471
|
+
INTAKE_ORIGIN: 'https://intake.test',
|
|
1472
|
+
TURNSTILE_SECRET: 'test',
|
|
1473
|
+
TURNSTILE_SITE_KEY: 'test-site-key',
|
|
1474
|
+
},
|
|
1475
|
+
})
|
|
1476
|
+
let restore = () => {}
|
|
1477
|
+
afterEach(() => restore())
|
|
1478
|
+
|
|
1479
|
+
describe('the test e-mail', () => {
|
|
1480
|
+
test("goes to the caller's own address, which the call does not name", async () => {
|
|
1481
|
+
const mail = standInMail()
|
|
1482
|
+
restore = mail.restore
|
|
1483
|
+
const lead = kit.testCaller([{ role: 'hr_lead' }], {
|
|
1484
|
+
email: 'Lead@Intake.test',
|
|
1485
|
+
})
|
|
1486
|
+
const api = createClient(contract, 'api', { fetch: kit.browser(lead) })
|
|
1487
|
+
expect(await api.settings.testMail()).toEqual({ sentTo: 'lead@intake.test' })
|
|
1488
|
+
expect(mail.sent.map((sent) => Mail.parse(JSON.parse(sent.body)).to))
|
|
1489
|
+
.toEqual([
|
|
1490
|
+
'lead@intake.test',
|
|
1491
|
+
])
|
|
1492
|
+
})
|
|
1493
|
+
})
|
|
1494
|
+
}
|
|
1495
|
+
```
|