@formancy/server 0.4.0 → 0.5.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.
@@ -1,1545 +0,0 @@
1
- import { createHash, randomBytes, randomUUID } from "node:crypto";
2
- import { auditedBy, authenticateApiKey, authenticateLocal, can, collectAbandonedFiles, createApiKey, createLocalUser, createSubmission, decodeSolution, exportCsv, healthOf, listForms, listSubmissions, listVersions, mintChallenge, offerUpload, publishForm, replayDelivery, resolveForm, resumeDraft, saveDraft, screenUpload, setFormAccess, startDraft, verifySolution } from "@formancy/server-core";
3
- import Fastify from "fastify";
4
- import rateLimit from "@fastify/rate-limit";
5
- import { hash, verify } from "@node-rs/argon2";
6
- import { SignJWT, jwtVerify } from "jose";
7
- import { and, desc, eq, inArray, lt, lte, max, ne } from "drizzle-orm";
8
- import { drizzle } from "drizzle-orm/postgres-js";
9
- import { integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
10
- import { createReadStream } from "node:fs";
11
- import { mkdir, rm, stat, writeFile } from "node:fs/promises";
12
- import { dirname, join, resolve, sep } from "node:path";
13
- //#region src/routes/files.ts
14
- /**
15
- * A form's files: offering one, receiving its bytes, and serving it back.
16
- *
17
- * One route family per plugin, the seam `app.ts`'s size budget names. It moved when
18
- * scanning an upload's bytes needed lines that file had no room for.
19
- */
20
- async function fileRoutes(app, { deps, fileStore, maxFileBytes, limit, actorOf }) {
21
- const { storage } = deps;
22
- const submissionLimit = limit;
23
- /**
24
- * Ask where to put a file.
25
- *
26
- * Refusing here, before a byte is sent, is the difference between rejecting
27
- * a 2 GB upload and receiving one first. The reply carries the id the
28
- * submission will reference and the URL to PUT the bytes to.
29
- */
30
- app.post("/f/:path/files", { config: { rateLimit: {
31
- max: submissionLimit.max,
32
- timeWindow: submissionLimit.timeWindowMs
33
- } } }, async (request, reply) => {
34
- if (fileStore === void 0) return reply.code(501).send({
35
- error: "uploads_unavailable",
36
- message: "This deployment has no file store configured, so it cannot accept uploads."
37
- });
38
- const { path } = request.params;
39
- const resolved = await resolveForm(deps, path);
40
- if (resolved === void 0) return reply.code(404).send({ error: "unknown_form" });
41
- const form = await storage.getFormByPath(path);
42
- if (form === void 0) return reply.code(404).send({ error: "unknown_form" });
43
- const actor = await actorOf(request);
44
- const origin = request.headers.origin;
45
- const body = request.body ?? {};
46
- const field = typeof body.field === "string" ? body.field : void 0;
47
- const name = typeof body.name === "string" ? body.name : void 0;
48
- const size = typeof body.size === "number" ? body.size : void 0;
49
- const contentType = typeof body.contentType === "string" ? body.contentType : "application/octet-stream";
50
- if (field === void 0 || name === void 0 || size === void 0) return reply.code(400).send({
51
- error: "invalid_request",
52
- message: "Send the field, the name and the size of the file you want to upload."
53
- });
54
- if (size > maxFileBytes) return reply.code(413).send({
55
- error: "too_large",
56
- message: `This deployment accepts files up to ${String(maxFileBytes)} bytes.`
57
- });
58
- const outcome = await offerUpload({
59
- storage,
60
- now: () => /* @__PURE__ */ new Date(),
61
- newId: () => randomUUID()
62
- }, {
63
- schema: resolved.schema,
64
- form,
65
- actor: actor === void 0 ? "anonymous" : "authenticated",
66
- origin: typeof origin === "string" ? origin : void 0,
67
- field,
68
- name,
69
- size,
70
- contentType
71
- });
72
- if (!outcome.ok) {
73
- const status = outcome.reason === "not_allowed" ? 403 : outcome.reason === "too_large" ? 413 : 400;
74
- return reply.code(status).send({ error: outcome.reason });
75
- }
76
- return reply.code(201).send({
77
- id: outcome.file.id,
78
- name: outcome.file.name,
79
- size: outcome.file.size,
80
- contentType: outcome.file.contentType,
81
- storageKey: outcome.file.storageKey,
82
- uploadUrl: `/f/${encodeURIComponent(path)}/files/${outcome.file.id}`
83
- });
84
- });
85
- /** Receive the bytes for a file that was offered. */
86
- app.put("/f/:path/files/:id", async (request, reply) => {
87
- if (fileStore === void 0) return reply.code(501).send({ error: "uploads_unavailable" });
88
- const { path, id } = request.params;
89
- const form = await storage.getFormByPath(path);
90
- if (form === void 0) return reply.code(404).send({ error: "unknown_form" });
91
- const file = await storage.getFile(id);
92
- if (file === void 0 || file.formId !== form.id) return reply.code(404).send({ error: "unknown_file" });
93
- if (file.state !== "offered") return reply.code(409).send({ error: "already_uploaded" });
94
- const bytes = request.body;
95
- if (!Buffer.isBuffer(bytes)) return reply.code(400).send({ error: "no_body" });
96
- if (bytes.byteLength > maxFileBytes) return reply.code(413).send({ error: "too_large" });
97
- if (bytes.byteLength !== file.size) return reply.code(400).send({
98
- error: "size_mismatch",
99
- message: `This upload was offered as ${String(file.size)} bytes and ${String(bytes.byteLength)} arrived.`
100
- });
101
- const screened = await screenUpload(deps.scanner, file, bytes);
102
- if (!screened.ok) {
103
- if (screened.reason === "refused") {
104
- request.log.warn({
105
- file: file.id,
106
- finding: screened.finding
107
- }, "upload refused by the scanner");
108
- return reply.code(422).send({
109
- error: "refused_by_scanner",
110
- message: `This file was refused by the deployment's virus scanner (${screened.finding}).`
111
- });
112
- }
113
- request.log.error({
114
- file: file.id,
115
- cause: screened.cause
116
- }, "the scanner could not be asked");
117
- return reply.code(503).send({
118
- error: "scanner_unavailable",
119
- message: "This file could not be scanned just now, so it was not kept. Try again."
120
- });
121
- }
122
- await fileStore.put(file.storageKey, bytes);
123
- await storage.updateFile({
124
- ...file,
125
- state: "stored"
126
- });
127
- return reply.code(204).send();
128
- });
129
- /**
130
- * Serve a file back.
131
- *
132
- * Always as an attachment, never inline, and `nosniff` alongside it. Stored
133
- * cross-site scripting through an uploaded HTML or SVG file is the most
134
- * commonly exploited vulnerability in this product category, and the real
135
- * answer is a separate hostname — which a single-container deployment does
136
- * not have. Forcing a download is the accommodation, and it is the one place
137
- * this deployment model genuinely costs something.
138
- */
139
- app.get("/f/:path/files/:id", async (request, reply) => {
140
- if (fileStore === void 0) return reply.code(501).send({ error: "uploads_unavailable" });
141
- const { path, id } = request.params;
142
- if (await actorOf(request) === void 0) return reply.code(401).send({ error: "unauthenticated" });
143
- const form = await storage.getFormByPath(path);
144
- const file = await storage.getFile(id);
145
- if (form === void 0 || file === void 0 || file.formId !== form.id) return reply.code(404).send({ error: "unknown_file" });
146
- const stream = await fileStore.open(file.storageKey);
147
- if (stream === void 0) return reply.code(404).send({ error: "unknown_file" });
148
- return reply.header("content-type", "application/octet-stream").header("x-content-type-options", "nosniff").header("content-security-policy", "default-src 'none'; sandbox").header("content-disposition", `attachment; filename="${file.name.replace(/["\r\n]/g, "")}"`).send(stream);
149
- });
150
- }
151
- //#endregion
152
- //#region src/headers.ts
153
- /**
154
- * The header a client declares what it was looking at with.
155
- *
156
- * One name, read by the submission route and by the publish plugin: a
157
- * submission says which version it rendered, and a publish says which version it
158
- * opened. Two spellings of one header is a bug nobody sees until a client sends
159
- * the other one.
160
- */
161
- const SCHEMA_HASH_HEADER = "x-formancy-schema-hash";
162
- //#endregion
163
- //#region src/routes/publish.ts
164
- /**
165
- * Publishing a form, as a plugin of its own.
166
- *
167
- * One route family per plugin, which is Fastify's own unit and what `app.ts`'s
168
- * size budget named as the seam. It moved when the budget refused the next thing
169
- * added to it rather than letting the number drift.
170
- */
171
- async function publishRoutes(app, { deps, requires }) {
172
- app.post("/forms", { preHandler: requires("form.publish") }, async (request, reply) => {
173
- const body = request.body;
174
- if (body === null || typeof body.path !== "string" || body.path === "" || body.schema === void 0) return reply.code(400).send({
175
- error: "bad_request",
176
- message: "Body needs { path, schema }."
177
- });
178
- const basedOn = request.headers[SCHEMA_HASH_HEADER];
179
- const outcome = await publishForm(deps, {
180
- path: body.path,
181
- schema: body.schema,
182
- actor: request.actor,
183
- ...typeof basedOn === "string" && basedOn !== "" ? { basedOnSchemaHash: basedOn } : {}
184
- });
185
- if (!outcome.ok) switch (outcome.kind) {
186
- case "invalid_schema": return reply.code(422).send({
187
- error: outcome.kind,
188
- errors: outcome.errors
189
- });
190
- case "unsafe_pattern": return reply.code(422).send({
191
- error: outcome.kind,
192
- patterns: outcome.patterns
193
- });
194
- case "invalid_logic": return reply.code(422).send({
195
- error: outcome.kind,
196
- message: outcome.message
197
- });
198
- case "unknown_options_source": return reply.code(422).send({
199
- error: outcome.kind,
200
- sources: outcome.sources
201
- });
202
- case "already_published": return reply.code(409).send({
203
- error: outcome.kind,
204
- version: outcome.version
205
- });
206
- case "version_changed": return reply.code(409).send({
207
- error: "FORM_VERSION_CHANGED",
208
- current: outcome.current === void 0 ? void 0 : {
209
- version: outcome.current.version,
210
- schemaHash: outcome.current.schemaHash,
211
- schema: outcome.current.schema
212
- }
213
- });
214
- }
215
- return reply.code(201).send({
216
- version: outcome.version,
217
- schemaHash: outcome.schemaHash,
218
- ...outcome.warnings.length > 0 ? { warnings: outcome.warnings } : {}
219
- });
220
- });
221
- }
222
- //#endregion
223
- //#region src/auth-runtime.ts
224
- /**
225
- * The real cryptography, confined to the composition root: argon2id for
226
- * secrets at rest, an HMAC-signed short-lived access token for sessions, and
227
- * a CSPRNG for API keys. server-core never sees any of this — it is handed
228
- * hash/verify/random functions and stays deterministic under test.
229
- *
230
- * HS256 rather than EdDSA for the session token, deliberately for this phase:
231
- * one secret in one env var is the self-hosting story a single-container
232
- * deployment can operate, and the signing scheme is an implementation detail
233
- * of a token that lives fifteen minutes. Revisit with key rotation.
234
- */
235
- function realSecretHashing() {
236
- return {
237
- hashSecret: (secret) => hash(secret),
238
- verifySecret: async (secret, digest) => {
239
- try {
240
- return await verify(digest, secret);
241
- } catch {
242
- return false;
243
- }
244
- }
245
- };
246
- }
247
- function realRandomToken() {
248
- return randomBytes(30).toString("base64url");
249
- }
250
- const TOKEN_LIFETIME = "15m";
251
- function createSessionTokens(secret) {
252
- if (secret.length < 32) throw new Error("FORMANCY_AUTH_SECRET must be at least 32 characters. Generate one: openssl rand -base64 33");
253
- const key = new TextEncoder().encode(secret);
254
- return {
255
- async issue(actor) {
256
- return new SignJWT({
257
- role: actor.role,
258
- kind: actor.kind
259
- }).setProtectedHeader({ alg: "HS256" }).setSubject(actor.id).setIssuedAt().setExpirationTime(TOKEN_LIFETIME).sign(key);
260
- },
261
- async verify(token) {
262
- try {
263
- const { payload } = await jwtVerify(token, key, { algorithms: ["HS256"] });
264
- const role = payload["role"];
265
- const kind = payload["kind"];
266
- if (typeof payload.sub !== "string" || role !== "admin" && role !== "editor" && role !== "viewer" || kind !== "user" && kind !== "apiKey") return;
267
- return {
268
- id: payload.sub,
269
- role,
270
- kind
271
- };
272
- } catch {
273
- return;
274
- }
275
- }
276
- };
277
- }
278
- //#endregion
279
- //#region src/app.ts
280
- /** Base64 JSON, the shape an ALTCHA client already produces. */
281
- const CHALLENGE_HEADER = "x-formancy-challenge";
282
- /** Proves the bearer started this draft. Lower-case: Fastify normalises. */
283
- const DRAFT_TOKEN_HEADER = "x-formancy-draft-token";
284
- const API_KEY_HEADER = "x-formancy-api-key";
285
- /**
286
- * The HTTP surface, split into two planes.
287
- *
288
- * PUBLIC — what a person filling a form needs: resolve, submit, drafts. These
289
- * stay unauthenticated by design; anonymous-submission hardening is its own
290
- * upcoming step and is opt-in per form.
291
- *
292
- * MANAGEMENT — everything an operator does: publish, catalog, submissions,
293
- * export, users, keys. Authenticated via a session token (login) or an API
294
- * key header, authorized through server-core's one-table can().
295
- *
296
- * This file is the composition root, so THIS is where ambient reality enters:
297
- * randomUUID, the real clock, argon2 and the CSPRNG. Everything below stays
298
- * injected and replayable.
299
- */
300
- async function createApp(storage, options) {
301
- const app = Fastify({
302
- logger: false,
303
- bodyLimit: options.bodyLimitBytes ?? 262144
304
- });
305
- const maxFileBytes = options.maxFileBytes ?? 10485760;
306
- const fileStore = options.fileStore;
307
- app.addContentTypeParser("*", {
308
- parseAs: "buffer",
309
- bodyLimit: maxFileBytes
310
- }, (_request, body, done) => {
311
- done(null, body);
312
- });
313
- const submissionLimit = options.submissionRateLimit ?? {
314
- max: 30,
315
- timeWindowMs: 6e4
316
- };
317
- const loginLimit = options.loginRateLimit ?? {
318
- max: 10,
319
- timeWindowMs: 6e4
320
- };
321
- const challengeSecret = options.challengeSecret;
322
- await app.register(rateLimit, {
323
- global: false,
324
- max: submissionLimit.max,
325
- timeWindow: submissionLimit.timeWindowMs
326
- });
327
- const deps = {
328
- storage,
329
- newId: () => randomUUID(),
330
- nowIso: () => (/* @__PURE__ */ new Date()).toISOString(),
331
- draftSecret: options.authSecret,
332
- ...options.optionsSources === void 0 ? {} : { optionsSources: options.optionsSources },
333
- ...options.scanner === void 0 ? {} : { scanner: options.scanner },
334
- capabilities: {
335
- now: () => Date.now(),
336
- today: () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
337
- random: () => Math.random()
338
- }
339
- };
340
- const authDeps = {
341
- storage,
342
- newId: deps.newId,
343
- nowIso: deps.nowIso,
344
- randomToken: realRandomToken,
345
- ...realSecretHashing()
346
- };
347
- const sessions = createSessionTokens(options.authSecret);
348
- if (options.bootstrapAdmin !== void 0) {
349
- if (await storage.getUserByEmail(options.bootstrapAdmin.email) === void 0) {
350
- const outcome = await createLocalUser(authDeps, {
351
- ...options.bootstrapAdmin,
352
- role: "admin"
353
- });
354
- if (!outcome.ok) throw new Error(`Bootstrap admin rejected: ${outcome.message}`);
355
- }
356
- }
357
- async function actorOf(request) {
358
- const bearer = request.headers.authorization;
359
- if (typeof bearer === "string" && bearer.startsWith("Bearer ")) return sessions.verify(bearer.slice(7));
360
- const apiKey = request.headers[API_KEY_HEADER];
361
- if (typeof apiKey === "string" && apiKey !== "") {
362
- const outcome = await authenticateApiKey(authDeps, apiKey);
363
- return outcome.ok ? outcome.actor : void 0;
364
- }
365
- }
366
- /** 401 without an identity, 403 with one that lacks the permission — the
367
- * difference tells a client whether to log in or to give up. */
368
- function requires(action) {
369
- return async (request, reply) => {
370
- const actor = await actorOf(request);
371
- if (actor === void 0) return reply.code(401).send({ error: "unauthenticated" });
372
- if (!can(actor, action)) return reply.code(403).send({
373
- error: "forbidden",
374
- action
375
- });
376
- request.actor = actor;
377
- };
378
- }
379
- /**
380
- * Append an audit row for something that has already happened.
381
- *
382
- * Never throws. An export that succeeded and an audit row that did not is
383
- * bad; an export that 500s because the audit row failed, after the rows have
384
- * already been written to the response, is worse and helps nobody. So the
385
- * failure is logged loudly at error level and the request stands.
386
- *
387
- * For a mutation that HAS a transaction, do not use this — pass the entry
388
- * into that call so the two commit together. `insertSubmission` is currently
389
- * the only one.
390
- */
391
- async function audit(request, draft) {
392
- const actor = request.actor;
393
- try {
394
- await deps.storage.recordAudit({
395
- id: deps.newId(),
396
- at: deps.nowIso(),
397
- requestId: request.id,
398
- ...auditedBy(actor, draft)
399
- });
400
- } catch (error) {
401
- request.log.error({
402
- err: error,
403
- action: draft.action
404
- }, "audit row could not be written");
405
- }
406
- }
407
- app.post("/auth/login", { config: { rateLimit: {
408
- max: loginLimit.max,
409
- timeWindow: loginLimit.timeWindowMs
410
- } } }, async (request, reply) => {
411
- const body = request.body;
412
- if (body === null || typeof body.email !== "string" || typeof body.password !== "string") return reply.code(400).send({
413
- error: "bad_request",
414
- message: "Body needs { email, password }."
415
- });
416
- const outcome = await authenticateLocal(authDeps, {
417
- email: body.email,
418
- password: body.password
419
- });
420
- if (!outcome.ok) {
421
- await audit(request, {
422
- action: "auth.login.failed",
423
- subject: body.email
424
- });
425
- return reply.code(401).send({ error: "invalid_credentials" });
426
- }
427
- await audit(request, {
428
- action: "auth.login",
429
- subject: body.email,
430
- actorKind: "user",
431
- actorId: outcome.actor.id
432
- });
433
- return reply.send({
434
- token: await sessions.issue(outcome.actor),
435
- role: outcome.actor.role
436
- });
437
- });
438
- app.post("/users", { preHandler: requires("user.create") }, async (request, reply) => {
439
- const body = request.body;
440
- if (body === null || typeof body.email !== "string" || typeof body.password !== "string" || body.role !== "admin" && body.role !== "editor" && body.role !== "viewer") return reply.code(400).send({
441
- error: "bad_request",
442
- message: "Body needs { email, password, role }."
443
- });
444
- const outcome = await createLocalUser(authDeps, {
445
- email: body.email,
446
- password: body.password,
447
- role: body.role
448
- });
449
- if (!outcome.ok) return reply.code(422).send({
450
- error: "rejected",
451
- message: outcome.message
452
- });
453
- return reply.code(201).send({ id: outcome.id });
454
- });
455
- app.post("/api-keys", { preHandler: requires("apiKey.create") }, async (request, reply) => {
456
- const body = request.body;
457
- if (body === null || typeof body.name !== "string" || body.role !== "admin" && body.role !== "editor" && body.role !== "viewer") return reply.code(400).send({
458
- error: "bad_request",
459
- message: "Body needs { name, role }."
460
- });
461
- const outcome = await createApiKey(authDeps, {
462
- name: body.name,
463
- role: body.role
464
- });
465
- if (!outcome.ok) return reply.code(422).send({
466
- error: "rejected",
467
- message: outcome.message
468
- });
469
- return reply.code(201).send({
470
- id: outcome.id,
471
- secret: outcome.secret
472
- });
473
- });
474
- await app.register(publishRoutes, {
475
- deps,
476
- requires
477
- });
478
- app.put("/f/:path/access", { preHandler: requires("form.publish") }, async (request, reply) => {
479
- const { path } = request.params;
480
- const body = request.body;
481
- const submit = body?.submit;
482
- if (submit !== "authenticated" && submit !== "public") return reply.code(400).send({
483
- error: "invalid_access",
484
- message: "submit must be \"authenticated\" or \"public\"."
485
- });
486
- const origins = body?.allowedOrigins;
487
- if (origins !== void 0 && !(Array.isArray(origins) && origins.every((o) => typeof o === "string"))) return reply.code(400).send({
488
- error: "invalid_access",
489
- message: "allowedOrigins must be an array of strings, or absent for no allowlist."
490
- });
491
- if (!(await setFormAccess(deps, {
492
- path,
493
- submit,
494
- ...origins === void 0 ? {} : { allowedOrigins: origins }
495
- })).ok) return reply.code(404).send({ error: "unknown_form" });
496
- await audit(request, {
497
- action: "form.access.changed",
498
- subject: path,
499
- detail: {
500
- submit,
501
- allowedOrigins: origins === void 0 ? "unchanged" : origins.join(" ")
502
- }
503
- });
504
- return reply.code(204).send();
505
- });
506
- /**
507
- * The log itself.
508
- *
509
- * Admin only, and reading it is NOT audited. A log that records its own
510
- * reads grows without bound from a dashboard that polls it, and the entry
511
- * would say nothing an access log does not — the events worth recording
512
- * are the ones that touch somebody else's data.
513
- */
514
- app.get("/audit", { preHandler: requires("user.create") }, async (request, reply) => {
515
- const raw = request.query?.limit;
516
- const asked = typeof raw === "string" ? Number.parseInt(raw, 10) : 100;
517
- const limit = Number.isFinite(asked) ? Math.min(Math.max(asked, 1), 500) : 100;
518
- return reply.send({ entries: await deps.storage.listAudit(limit) });
519
- });
520
- /**
521
- * Webhook health, and the deliveries that died.
522
- *
523
- * The reason the breaker keeps its counters on the row rather than in the
524
- * worker's memory. A self-hoster has no operations team watching a
525
- * dashboard, so a destination refusing deliveries since Tuesday has to be
526
- * answerable from the product — otherwise it is found when somebody asks
527
- * why the CRM has no leads this week.
528
- */
529
- app.get("/webhooks", { preHandler: requires("form.publish") }, async (_request, reply) => {
530
- const now = new Date(deps.nowIso());
531
- const hooks = await deps.storage.listWebhooks();
532
- return reply.send({ webhooks: hooks.map((hook) => healthOf(hook, now)) });
533
- });
534
- app.get("/deliveries/dead", { preHandler: requires("form.publish") }, async (request, reply) => {
535
- const raw = request.query?.limit;
536
- const asked = typeof raw === "string" ? Number.parseInt(raw, 10) : 50;
537
- const limit = Number.isFinite(asked) ? Math.min(Math.max(asked, 1), 200) : 50;
538
- const dead = await deps.storage.deadDeliveries(limit);
539
- return reply.send({ deliveries: dead.map((delivery) => ({
540
- id: delivery.id,
541
- webhookId: delivery.webhookId,
542
- submissionId: delivery.submissionId,
543
- eventId: delivery.eventId,
544
- attempt: delivery.attempt,
545
- lastError: delivery.lastError
546
- })) });
547
- });
548
- app.post("/deliveries/:id/replay", { preHandler: requires("form.publish") }, async (request, reply) => {
549
- const { id } = request.params;
550
- const outcome = await replayDelivery({
551
- storage: deps.storage,
552
- now: () => new Date(deps.nowIso())
553
- }, id);
554
- if (!outcome.ok) return outcome.kind === "unknown" ? reply.code(404).send({ error: "unknown_delivery" }) : reply.code(409).send({
555
- error: "not_dead",
556
- state: outcome.state,
557
- message: "Only a dead delivery can be replayed. A pending one is already queued, and a delivered one would be sent twice."
558
- });
559
- await audit(request, {
560
- action: "delivery.replayed",
561
- subject: id,
562
- detail: { webhookId: outcome.delivery.webhookId }
563
- });
564
- return reply.code(202).send({
565
- id,
566
- state: outcome.delivery.state
567
- });
568
- });
569
- /**
570
- * A puzzle for an anonymous visitor to solve before submitting.
571
- *
572
- * Public because the form is: requiring a session to obtain a challenge for
573
- * an unauthenticated submission would be a circle. It is cheap to mint and
574
- * stateless, so handing one out costs this server a hash.
575
- */
576
- app.get("/f/:path/challenge", { config: { rateLimit: {
577
- max: submissionLimit.max,
578
- timeWindow: submissionLimit.timeWindowMs
579
- } } }, async (request, reply) => {
580
- if (challengeSecret === void 0) return reply.code(404).send({ error: "challenge_not_enabled" });
581
- const { path } = request.params;
582
- if (await deps.storage.getFormByPath(path) === void 0) return reply.code(404).send({ error: "unknown_form" });
583
- return reply.send(await mintChallenge({
584
- secret: challengeSecret,
585
- nowSeconds: Math.floor(new Date(deps.nowIso()).getTime() / 1e3)
586
- }));
587
- });
588
- /**
589
- * Check a submitted solution, and spend it.
590
- *
591
- * Returns the refusal to send, or undefined when it passed. The spend is
592
- * separate from the verification and comes last: the stateless checks say
593
- * the solution is correct, and only the database can say it has not been
594
- * used — a correct solution stays correct, so without this one puzzle
595
- * would buy a thousand submissions.
596
- */
597
- async function checkChallenge(request) {
598
- const header = request.headers[CHALLENGE_HEADER];
599
- if (typeof header !== "string" || header === "") return {
600
- error: "challenge_required",
601
- message: `This form needs a solved challenge. Ask GET /f/:path/challenge for one and return it in ${CHALLENGE_HEADER}.`
602
- };
603
- const solution = decodeSolution(header);
604
- if (solution === void 0) return {
605
- error: "challenge_malformed",
606
- message: "The challenge header is not base64 JSON."
607
- };
608
- const nowSeconds = Math.floor(new Date(deps.nowIso()).getTime() / 1e3);
609
- const verified = await verifySolution(challengeSecret, solution, nowSeconds);
610
- if (!verified.ok) return {
611
- error: `challenge_${verified.reason}`,
612
- message: "The challenge was not solved. Ask for a new one and try again."
613
- };
614
- if (!await deps.storage.spendChallenge(verified.challenge, (/* @__PURE__ */ new Date(verified.expiresAtSeconds * 1e3)).toISOString())) return {
615
- error: "challenge_spent",
616
- message: "That challenge has already been used. Each one is good for one submission."
617
- };
618
- }
619
- app.get("/forms", { preHandler: requires("form.read") }, async (_request, reply) => {
620
- return reply.send({ forms: await listForms(deps) });
621
- });
622
- app.get("/f/:path/versions", { preHandler: requires("form.read") }, async (request, reply) => {
623
- const { path } = request.params;
624
- const versions = await listVersions(deps, path);
625
- if (versions === void 0) return reply.code(404).send({ error: "unknown_form" });
626
- return reply.send({ versions });
627
- });
628
- app.get("/f/:path/submissions", { preHandler: requires("submission.read") }, async (request, reply) => {
629
- const { path } = request.params;
630
- const listed = await listSubmissions(deps, path);
631
- if (listed === void 0) return reply.code(404).send({ error: "unknown_form" });
632
- await audit(request, {
633
- action: "submission.read",
634
- subject: path,
635
- detail: { count: listed.length }
636
- });
637
- return reply.send({ submissions: listed });
638
- });
639
- app.get("/f/:path/submissions/export.csv", { preHandler: requires("submission.export") }, async (request, reply) => {
640
- const { path } = request.params;
641
- const csv = await exportCsv(deps, path);
642
- if (csv === void 0) return reply.code(404).send({ error: "unknown_form" });
643
- await audit(request, {
644
- action: "submission.exported",
645
- subject: path,
646
- detail: { bytes: Buffer.byteLength(csv, "utf8") }
647
- });
648
- return reply.header("content-type", "text/csv; charset=utf-8").header("content-disposition", `attachment; filename="${path}-submissions.csv"`).send(csv);
649
- });
650
- /**
651
- * Fetch a published form. The one public route deliberately NOT rate-limited.
652
- *
653
- * Every other unauthenticated route is: submitting, starting a draft, writing
654
- * one, reading one, minting a challenge, asking for an upload target. Those are
655
- * writes, or guesses, or work somebody can demand of the server. This is the
656
- * read every visitor has to make before they can do anything at all.
657
- *
658
- * Limiting it by IP would refuse the form to real people sharing an address —
659
- * an office, a school, a phone network behind CGNAT — and the failure would
660
- * look like the form being broken rather than like a limit. That is a worse
661
- * outcome than the cheap read it would prevent, and the read is cached by
662
- * `schemaHash` anyway.
663
- *
664
- * Written down because the asymmetry is deliberate and looks like an omission.
665
- */
666
- app.get("/f/:path", async (request, reply) => {
667
- const { path } = request.params;
668
- const resolved = await resolveForm(deps, path);
669
- if (resolved === void 0) return reply.code(404).send({ error: "unknown_form" });
670
- return reply.send({
671
- version: resolved.version,
672
- schemaHash: resolved.schemaHash,
673
- schema: resolved.schema
674
- });
675
- });
676
- /**
677
- * Start a draft, and get the only key to it.
678
- *
679
- * The public plane is anonymous, so a draft has no account behind it. It was
680
- * previously addressed by an id the CALLER chose, with no check on either
681
- * route: anybody who knew or guessed an id could read a stranger's part-filled
682
- * form, and overwrite it — after which that person submits the substituted
683
- * content under their own name and nothing says otherwise
684
- * ([0062](../../../docs/decisions/0062-a-draft-carries-its-own-key.md)).
685
- *
686
- * So the server picks the id and signs it, and the token comes back exactly
687
- * once. Losing it means losing the draft, which is the correct trade for the
688
- * alternative.
689
- */
690
- app.post("/f/:path/drafts", { config: { rateLimit: {
691
- max: submissionLimit.max,
692
- timeWindow: submissionLimit.timeWindowMs
693
- } } }, async (request, reply) => {
694
- const { path } = request.params;
695
- const started = await startDraft(deps, { path });
696
- if (started === void 0) return reply.code(404).send({ error: "unknown_form" });
697
- return reply.code(201).send(started);
698
- });
699
- app.put("/f/:path/drafts/:draftId", { config: { rateLimit: {
700
- max: submissionLimit.max,
701
- timeWindow: submissionLimit.timeWindowMs
702
- } } }, async (request, reply) => {
703
- const { path, draftId } = request.params;
704
- const token = request.headers[DRAFT_TOKEN_HEADER];
705
- if (typeof token !== "string") return reply.code(401).send({ error: "draft_token_required" });
706
- const saved = await saveDraft(deps, {
707
- path,
708
- draftId,
709
- token,
710
- data: request.body ?? {}
711
- });
712
- if (saved === void 0) return reply.code(404).send({ error: "unknown_form" });
713
- if (!saved.saved) return reply.code(403).send({ error: "draft_token_invalid" });
714
- return reply.send({ version: saved.version });
715
- });
716
- app.get("/f/:path/drafts/:draftId", { config: { rateLimit: {
717
- max: submissionLimit.max,
718
- timeWindow: submissionLimit.timeWindowMs
719
- } } }, async (request, reply) => {
720
- const { path, draftId } = request.params;
721
- const token = request.headers[DRAFT_TOKEN_HEADER];
722
- if (typeof token !== "string") return reply.code(401).send({ error: "draft_token_required" });
723
- const resumed = await resumeDraft(deps, {
724
- path,
725
- draftId,
726
- token
727
- });
728
- if (resumed === void 0) return reply.code(404).send({ error: "unknown_draft" });
729
- return reply.send(resumed);
730
- });
731
- await app.register(fileRoutes, {
732
- deps,
733
- fileStore,
734
- maxFileBytes,
735
- limit: submissionLimit,
736
- actorOf
737
- });
738
- app.post("/f/:path/submissions", { config: { rateLimit: {
739
- max: submissionLimit.max,
740
- timeWindow: submissionLimit.timeWindowMs
741
- } } }, async (request, reply) => {
742
- const { path } = request.params;
743
- const declaredSchemaHash = request.headers[SCHEMA_HASH_HEADER];
744
- if (typeof declaredSchemaHash !== "string" || declaredSchemaHash === "") return reply.code(400).send({
745
- error: "missing_schema_hash",
746
- message: `Send the schema hash you rendered in the ${SCHEMA_HASH_HEADER} header.`
747
- });
748
- const actor = await actorOf(request);
749
- const origin = request.headers.origin;
750
- if (challengeSecret !== void 0 && actor === void 0) {
751
- const refusal = await checkChallenge(request);
752
- if (refusal !== void 0) return reply.code(400).send(refusal);
753
- }
754
- const outcome = await createSubmission(deps, {
755
- path,
756
- declaredSchemaHash,
757
- data: request.body ?? {},
758
- actor: actor === void 0 ? "anonymous" : "authenticated",
759
- ...typeof origin === "string" ? { origin } : {}
760
- });
761
- if (outcome.ok) return reply.code(201).send({
762
- id: outcome.id,
763
- data: outcome.canonicalData
764
- });
765
- switch (outcome.kind) {
766
- case "unknown_form": return reply.code(404).send({ error: "unknown_form" });
767
- case "version_changed": return reply.code(409).send({
768
- error: "FORM_VERSION_CHANGED",
769
- current: outcome.current === void 0 ? void 0 : {
770
- version: outcome.current.version,
771
- schemaHash: outcome.current.schemaHash,
772
- schema: outcome.current.schema
773
- }
774
- });
775
- case "invalid": return reply.code(422).send({
776
- error: "invalid",
777
- errors: outcome.errors
778
- });
779
- case "forbidden": return reply.code(403).send({ error: "forbidden" });
780
- case "source_unavailable": return reply.code(503).send({
781
- error: "source_unavailable",
782
- source: outcome.source
783
- });
784
- }
785
- });
786
- return app;
787
- }
788
- //#endregion
789
- //#region src/db.ts
790
- /**
791
- * The thin slice's tables. Three rules from the design spec are enforced HERE,
792
- * in the database, because application code can be bypassed and these must not
793
- * be:
794
- *
795
- * - published versions are immutable (a trigger refuses UPDATE),
796
- * - a submission binds to its exact version by real foreign key,
797
- * - that key is ON DELETE RESTRICT, so orphaning a submission from the schema
798
- * that produced it is structurally impossible.
799
- */
800
- const forms = pgTable("forms", {
801
- id: uuid("id").primaryKey(),
802
- path: text("path").notNull().unique(),
803
- currentVersionId: uuid("current_version_id"),
804
- accessSubmit: text("access_submit").notNull().default("authenticated"),
805
- allowedOrigins: text("allowed_origins").array(),
806
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
807
- });
808
- const formVersions = pgTable("form_versions", {
809
- id: uuid("id").primaryKey(),
810
- formId: uuid("form_id").notNull().references(() => forms.id),
811
- version: integer("version").notNull(),
812
- schema: jsonb("schema").notNull(),
813
- schemaHash: text("schema_hash").notNull(),
814
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
815
- });
816
- const users = pgTable("users", {
817
- id: uuid("id").primaryKey(),
818
- email: text("email").notNull().unique(),
819
- passwordHash: text("password_hash").notNull(),
820
- role: text("role").notNull(),
821
- createdAt: timestamp("created_at", { withTimezone: true }).notNull()
822
- });
823
- const apiKeys = pgTable("api_keys", {
824
- id: uuid("id").primaryKey(),
825
- name: text("name").notNull(),
826
- prefix: text("prefix").notNull(),
827
- secretHash: text("secret_hash").notNull(),
828
- role: text("role").notNull(),
829
- createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
830
- revokedAt: timestamp("revoked_at", { withTimezone: true })
831
- });
832
- const drafts = pgTable("drafts", {
833
- id: text("id").notNull(),
834
- formId: uuid("form_id").notNull().references(() => forms.id),
835
- formVersionId: uuid("form_version_id").notNull().references(() => formVersions.id),
836
- data: jsonb("data").notNull(),
837
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull()
838
- });
839
- const webhooks = pgTable("webhooks", {
840
- id: uuid("id").primaryKey(),
841
- formId: uuid("form_id").notNull().references(() => forms.id),
842
- url: text("url").notNull(),
843
- secret: text("secret").notNull(),
844
- consecutiveFailures: integer("consecutive_failures").notNull().default(0),
845
- openedAt: timestamp("opened_at", { withTimezone: true })
846
- });
847
- /**
848
- * The outbox. A row here and the submission that caused it are written by the
849
- * same transaction, which is what makes "accepted" and "will be delivered" one
850
- * decision rather than two.
851
- */
852
- const deliveries = pgTable("deliveries", {
853
- id: uuid("id").primaryKey(),
854
- webhookId: uuid("webhook_id").notNull(),
855
- submissionId: uuid("submission_id").notNull(),
856
- eventId: uuid("event_id").notNull(),
857
- body: text("body").notNull(),
858
- attempt: integer("attempt").notNull().default(0),
859
- nextAttemptAt: timestamp("next_attempt_at", { withTimezone: true }).notNull(),
860
- state: text("state").notNull().default("pending"),
861
- lastError: text("last_error")
862
- });
863
- const spentChallenges = pgTable("spent_challenges", {
864
- challenge: text("challenge").primaryKey(),
865
- expiresAt: timestamp("expires_at", { withTimezone: true }).notNull()
866
- });
867
- const auditLog = pgTable("audit_log", {
868
- id: uuid("id").primaryKey(),
869
- at: timestamp("at", { withTimezone: true }).notNull(),
870
- action: text("action").notNull(),
871
- actorKind: text("actor_kind"),
872
- actorId: text("actor_id"),
873
- subject: text("subject"),
874
- requestId: text("request_id"),
875
- detail: jsonb("detail")
876
- });
877
- const files = pgTable("files", {
878
- id: uuid("id").primaryKey(),
879
- formId: uuid("form_id").notNull(),
880
- name: text("name").notNull(),
881
- size: integer("size").notNull(),
882
- contentType: text("content_type").notNull(),
883
- storageKey: text("storage_key").notNull(),
884
- state: text("state").notNull().default("offered"),
885
- createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
886
- submissionId: uuid("submission_id")
887
- });
888
- const submissions = pgTable("submissions", {
889
- id: uuid("id").primaryKey(),
890
- formId: uuid("form_id").notNull().references(() => forms.id),
891
- formVersionId: uuid("form_version_id").notNull().references(() => formVersions.id, { onDelete: "restrict" }),
892
- data: jsonb("data").notNull(),
893
- submittedAt: timestamp("submitted_at", { withTimezone: true }).notNull()
894
- });
895
- /**
896
- * Bootstrap DDL for the thin slice — drizzle-kit migrations arrive with the
897
- * lifecycle work; a self-hosted skeleton that creates its own tables on first
898
- * start is worth more right now than a migration pipeline.
899
- */
900
- async function bootstrapSchema(sql) {
901
- await sql`
902
- CREATE TABLE IF NOT EXISTS forms (
903
- id uuid PRIMARY KEY,
904
- path text NOT NULL UNIQUE,
905
- current_version_id uuid,
906
- -- Private by default in the DATABASE, not only in the code, so a row
907
- -- inserted by a migration or a fixture is not accidentally open.
908
- access_submit text NOT NULL DEFAULT 'authenticated'
909
- CHECK (access_submit IN ('authenticated', 'public')),
910
- -- NULL: no allowlist in force. Empty array: nothing allowed. Different
911
- -- things, which is why this is nullable rather than defaulting to '{}'.
912
- allowed_origins text[],
913
- created_at timestamptz NOT NULL DEFAULT now()
914
- )`;
915
- await sql`
916
- ALTER TABLE forms
917
- ADD COLUMN IF NOT EXISTS access_submit text NOT NULL DEFAULT 'authenticated'`;
918
- await sql`ALTER TABLE forms ADD COLUMN IF NOT EXISTS allowed_origins text[]`;
919
- await sql`
920
- CREATE TABLE IF NOT EXISTS form_versions (
921
- id uuid PRIMARY KEY,
922
- form_id uuid NOT NULL REFERENCES forms(id),
923
- version integer NOT NULL,
924
- schema jsonb NOT NULL,
925
- schema_hash text NOT NULL,
926
- created_at timestamptz NOT NULL DEFAULT now(),
927
- UNIQUE (form_id, version),
928
- UNIQUE (form_id, schema_hash)
929
- )`;
930
- await sql`
931
- CREATE TABLE IF NOT EXISTS submissions (
932
- id uuid PRIMARY KEY,
933
- form_id uuid NOT NULL REFERENCES forms(id),
934
- form_version_id uuid NOT NULL REFERENCES form_versions(id) ON DELETE RESTRICT,
935
- data jsonb NOT NULL,
936
- submitted_at timestamptz NOT NULL
937
- )`;
938
- await sql`
939
- CREATE TABLE IF NOT EXISTS webhooks (
940
- id uuid PRIMARY KEY,
941
- form_id uuid NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
942
- url text NOT NULL,
943
- secret text NOT NULL
944
- )`;
945
- await sql`
946
- ALTER TABLE webhooks
947
- ADD COLUMN IF NOT EXISTS consecutive_failures integer NOT NULL DEFAULT 0,
948
- -- When the breaker opened, null while the destination is answering. On
949
- -- the row rather than in the worker's memory: memory does not survive a
950
- -- restart, and a self-hoster with no operations team needs a failing
951
- -- webhook to be visible on a screen rather than in a log nobody tails.
952
- ADD COLUMN IF NOT EXISTS opened_at timestamptz`;
953
- await sql`
954
- CREATE TABLE IF NOT EXISTS deliveries (
955
- id uuid PRIMARY KEY,
956
- webhook_id uuid NOT NULL REFERENCES webhooks(id) ON DELETE CASCADE,
957
- -- RESTRICT, not CASCADE: a delivery is the record that something was
958
- -- sent about this submission, and deleting the submission must not
959
- -- quietly erase it.
960
- submission_id uuid NOT NULL REFERENCES submissions(id) ON DELETE RESTRICT,
961
- event_id uuid NOT NULL,
962
- body text NOT NULL,
963
- attempt integer NOT NULL DEFAULT 0,
964
- next_attempt_at timestamptz NOT NULL,
965
- state text NOT NULL DEFAULT 'pending'
966
- CHECK (state IN ('pending', 'delivered', 'dead')),
967
- last_error text
968
- )`;
969
- await sql`
970
- CREATE INDEX IF NOT EXISTS deliveries_due
971
- ON deliveries (next_attempt_at) WHERE state = 'pending'`;
972
- await sql`
973
- CREATE TABLE IF NOT EXISTS files (
974
- id uuid PRIMARY KEY,
975
- form_id uuid NOT NULL REFERENCES forms(id) ON DELETE RESTRICT,
976
- -- What the reader called it. Display only: the storage key is minted by
977
- -- the server, because a key built from a submitted filename is a path
978
- -- traversal waiting for somebody to try it.
979
- name text NOT NULL,
980
- size integer NOT NULL,
981
- content_type text NOT NULL,
982
- storage_key text NOT NULL,
983
- state text NOT NULL DEFAULT 'offered'
984
- CHECK (state IN ('offered', 'stored', 'claimed')),
985
- created_at timestamptz NOT NULL,
986
- -- RESTRICT for the same reason a delivery uses it: the row is the record
987
- -- that something was attached, and deleting the submission must not
988
- -- quietly erase what it carried.
989
- submission_id uuid REFERENCES submissions(id) ON DELETE RESTRICT,
990
- -- One file, one submission. Enforced here rather than in application
991
- -- code, because two concurrent submissions claiming the same file is
992
- -- exactly the race application code loses — and the loser would be a
993
- -- submission referencing bytes another submission owns.
994
- CONSTRAINT files_one_submission UNIQUE (id, submission_id)
995
- )`;
996
- await sql`
997
- CREATE INDEX IF NOT EXISTS files_abandoned
998
- ON files (created_at) WHERE state <> 'claimed'`;
999
- await sql`
1000
- CREATE TABLE IF NOT EXISTS users (
1001
- id uuid PRIMARY KEY,
1002
- email text NOT NULL UNIQUE,
1003
- password_hash text NOT NULL,
1004
- role text NOT NULL,
1005
- created_at timestamptz NOT NULL
1006
- )`;
1007
- await sql`
1008
- CREATE TABLE IF NOT EXISTS api_keys (
1009
- id uuid PRIMARY KEY,
1010
- name text NOT NULL,
1011
- prefix text NOT NULL,
1012
- secret_hash text NOT NULL,
1013
- role text NOT NULL,
1014
- created_at timestamptz NOT NULL,
1015
- revoked_at timestamptz
1016
- )`;
1017
- await sql`CREATE INDEX IF NOT EXISTS api_keys_prefix ON api_keys (prefix)`;
1018
- await sql`
1019
- CREATE TABLE IF NOT EXISTS spent_challenges (
1020
- -- The primary key IS the mechanism. Two requests arriving with one
1021
- -- solution both pass every stateless check — the signature is ours
1022
- -- and the hash is right — and only the database can decide which of
1023
- -- them spends it. A read-then-write would race exactly where somebody
1024
- -- is looking.
1025
- challenge text PRIMARY KEY,
1026
- expires_at timestamptz NOT NULL
1027
- )`;
1028
- await sql`CREATE INDEX IF NOT EXISTS spent_challenges_expiry ON spent_challenges (expires_at)`;
1029
- await sql`
1030
- CREATE TABLE IF NOT EXISTS audit_log (
1031
- id uuid PRIMARY KEY,
1032
- at timestamptz NOT NULL,
1033
- action text NOT NULL,
1034
- -- Null for an anonymous public submission, which is an answer rather
1035
- -- than a gap: the form was open and nobody was signed in.
1036
- actor_kind text CHECK (actor_kind IN ('user', 'apiKey')),
1037
- actor_id text,
1038
- subject text,
1039
- request_id text,
1040
- -- Identifiers and counts. NEVER a submission's answers: an audit log is
1041
- -- read by more people and kept longer than the data it describes, so
1042
- -- data inside it is a second copy of the thing being protected.
1043
- detail jsonb
1044
- )`;
1045
- await sql`CREATE INDEX IF NOT EXISTS audit_log_at ON audit_log (at DESC)`;
1046
- await sql`CREATE INDEX IF NOT EXISTS audit_log_subject ON audit_log (subject, at DESC)`;
1047
- await sql`
1048
- CREATE TABLE IF NOT EXISTS drafts (
1049
- id text NOT NULL,
1050
- form_id uuid NOT NULL REFERENCES forms(id),
1051
- form_version_id uuid NOT NULL REFERENCES form_versions(id),
1052
- data jsonb NOT NULL,
1053
- updated_at timestamptz NOT NULL,
1054
- PRIMARY KEY (form_id, id)
1055
- )`;
1056
- await sql`
1057
- CREATE OR REPLACE FUNCTION refuse_version_update() RETURNS trigger AS $$
1058
- BEGIN
1059
- RAISE EXCEPTION 'form_versions rows are immutable; publish a new version instead';
1060
- END
1061
- $$ LANGUAGE plpgsql`;
1062
- await sql`DROP TRIGGER IF EXISTS form_versions_immutable ON form_versions`;
1063
- await sql`
1064
- CREATE TRIGGER form_versions_immutable
1065
- BEFORE UPDATE ON form_versions
1066
- FOR EACH ROW EXECUTE FUNCTION refuse_version_update()`;
1067
- await sql`
1068
- CREATE OR REPLACE FUNCTION refuse_audit_change() RETURNS trigger AS $$
1069
- BEGIN
1070
- RAISE EXCEPTION 'audit_log is append-only';
1071
- END
1072
- $$ LANGUAGE plpgsql`;
1073
- await sql`DROP TRIGGER IF EXISTS audit_log_append_only ON audit_log`;
1074
- await sql`
1075
- CREATE TRIGGER audit_log_append_only
1076
- BEFORE UPDATE OR DELETE ON audit_log
1077
- FOR EACH ROW EXECUTE FUNCTION refuse_audit_change()`;
1078
- }
1079
- //#endregion
1080
- //#region src/postgres-storage.ts
1081
- /** The Storage port over Postgres — the mirror of server-core's in-memory one. */
1082
- function createPostgresStorage(sql) {
1083
- const db = drizzle(sql);
1084
- return {
1085
- async getFormByPath(path) {
1086
- const row = (await db.select().from(forms).where(eq(forms.path, path)).limit(1))[0];
1087
- if (row === void 0) return void 0;
1088
- return {
1089
- id: row.id,
1090
- path: row.path,
1091
- currentVersionId: row.currentVersionId,
1092
- accessSubmit: row.accessSubmit === "public" ? "public" : "authenticated",
1093
- allowedOrigins: row.allowedOrigins
1094
- };
1095
- },
1096
- async listForms() {
1097
- return (await db.select().from(forms)).map((row) => ({
1098
- id: row.id,
1099
- path: row.path,
1100
- currentVersionId: row.currentVersionId,
1101
- accessSubmit: row.accessSubmit === "public" ? "public" : "authenticated",
1102
- allowedOrigins: row.allowedOrigins
1103
- }));
1104
- },
1105
- async createForm(record) {
1106
- await db.insert(forms).values({
1107
- id: record.id,
1108
- path: record.path,
1109
- currentVersionId: record.currentVersionId,
1110
- accessSubmit: record.accessSubmit,
1111
- allowedOrigins: record.allowedOrigins
1112
- });
1113
- },
1114
- async updateFormAccess(formId, access) {
1115
- await db.update(forms).set({
1116
- accessSubmit: access.accessSubmit,
1117
- allowedOrigins: access.allowedOrigins
1118
- }).where(eq(forms.id, formId));
1119
- },
1120
- async setCurrentVersion(formId, versionId) {
1121
- await db.update(forms).set({ currentVersionId: versionId }).where(eq(forms.id, formId));
1122
- },
1123
- async insertVersion(record) {
1124
- await db.insert(formVersions).values({
1125
- id: record.id,
1126
- formId: record.formId,
1127
- version: record.version,
1128
- schema: record.schema,
1129
- schemaHash: record.schemaHash
1130
- });
1131
- },
1132
- async publishVersion({ form, version, audit }) {
1133
- await db.transaction(async (tx) => {
1134
- if (form !== void 0) await tx.insert(forms).values({
1135
- id: form.id,
1136
- path: form.path,
1137
- currentVersionId: form.currentVersionId,
1138
- accessSubmit: form.accessSubmit,
1139
- allowedOrigins: form.allowedOrigins
1140
- });
1141
- await tx.insert(formVersions).values({
1142
- id: version.id,
1143
- formId: version.formId,
1144
- version: version.version,
1145
- schema: version.schema,
1146
- schemaHash: version.schemaHash
1147
- });
1148
- if ((await tx.update(forms).set({ currentVersionId: version.id }).where(eq(forms.id, version.formId)).returning({ id: forms.id })).length !== 1) throw new Error(`Publishing to form ${version.formId} matched no form row. Rolling back, so there is no version pointing at a form that is not there.`);
1149
- if (audit !== void 0) await tx.insert(auditLog).values(auditRow(audit));
1150
- });
1151
- },
1152
- async getVersionById(id) {
1153
- const row = (await db.select().from(formVersions).where(eq(formVersions.id, id)).limit(1))[0];
1154
- if (row === void 0) return void 0;
1155
- return {
1156
- id: row.id,
1157
- formId: row.formId,
1158
- version: row.version,
1159
- schema: row.schema,
1160
- schemaHash: row.schemaHash
1161
- };
1162
- },
1163
- async findVersionByHash(formId, schemaHash) {
1164
- const row = (await db.select().from(formVersions).where(eq(formVersions.formId, formId))).find((candidate) => candidate.schemaHash === schemaHash);
1165
- if (row === void 0) return void 0;
1166
- return {
1167
- id: row.id,
1168
- formId: row.formId,
1169
- version: row.version,
1170
- schema: row.schema,
1171
- schemaHash: row.schemaHash
1172
- };
1173
- },
1174
- async latestVersionNumber(formId) {
1175
- return (await db.select({ latest: max(formVersions.version) }).from(formVersions).where(eq(formVersions.formId, formId)))[0]?.latest ?? 0;
1176
- },
1177
- async insertSubmission(record, queued, claimFileIds, audit) {
1178
- await db.transaction(async (tx) => {
1179
- await tx.insert(submissions).values({
1180
- id: record.id,
1181
- formId: record.formId,
1182
- formVersionId: record.formVersionId,
1183
- data: record.data,
1184
- submittedAt: new Date(record.submittedAt)
1185
- });
1186
- if (queued !== void 0 && queued.length > 0) await tx.insert(deliveries).values(queued.map((delivery) => ({
1187
- id: delivery.id,
1188
- webhookId: delivery.webhookId,
1189
- submissionId: delivery.submissionId,
1190
- eventId: delivery.eventId,
1191
- body: delivery.body,
1192
- attempt: delivery.attempt,
1193
- nextAttemptAt: new Date(delivery.nextAttemptAt),
1194
- state: delivery.state,
1195
- lastError: delivery.lastError
1196
- })));
1197
- if (claimFileIds !== void 0 && claimFileIds.length > 0) {
1198
- if ((await tx.update(files).set({
1199
- state: "claimed",
1200
- submissionId: record.id
1201
- }).where(and(inArray(files.id, [...claimFileIds]), eq(files.state, "stored"))).returning({ id: files.id })).length !== claimFileIds.length) throw new Error("A file this submission names was claimed by another one first. Rolling back, so the submission does not reference bytes it does not own.");
1202
- }
1203
- if (audit !== void 0) await tx.insert(auditLog).values(auditRow(audit));
1204
- });
1205
- },
1206
- async spendChallenge(challenge, expiresAtIso) {
1207
- return (await db.insert(spentChallenges).values({
1208
- challenge,
1209
- expiresAt: new Date(expiresAtIso)
1210
- }).onConflictDoNothing().returning({ challenge: spentChallenges.challenge })).length === 1;
1211
- },
1212
- async forgetExpiredChallenges(beforeIso) {
1213
- return (await db.delete(spentChallenges).where(lt(spentChallenges.expiresAt, new Date(beforeIso))).returning({ challenge: spentChallenges.challenge })).length;
1214
- },
1215
- async recordAudit(entry) {
1216
- await db.insert(auditLog).values(auditRow(entry));
1217
- },
1218
- async listAudit(limit) {
1219
- return (await db.select().from(auditLog).orderBy(desc(auditLog.at)).limit(limit)).map((row) => ({
1220
- id: row.id,
1221
- at: row.at.toISOString(),
1222
- action: row.action,
1223
- ...row.actorKind === null ? {} : { actorKind: row.actorKind },
1224
- ...row.actorId === null ? {} : { actorId: row.actorId },
1225
- ...row.subject === null ? {} : { subject: row.subject },
1226
- ...row.requestId === null ? {} : { requestId: row.requestId },
1227
- ...row.detail === null ? {} : { detail: row.detail }
1228
- }));
1229
- },
1230
- async insertFile(record) {
1231
- await db.insert(files).values({
1232
- ...record,
1233
- createdAt: new Date(record.createdAt)
1234
- });
1235
- },
1236
- async getFile(id) {
1237
- const [row] = await db.select().from(files).where(eq(files.id, id)).limit(1);
1238
- return row === void 0 ? void 0 : toFileRecord(row);
1239
- },
1240
- async updateFile(record) {
1241
- await db.update(files).set({
1242
- state: record.state,
1243
- submissionId: record.submissionId
1244
- }).where(eq(files.id, record.id));
1245
- },
1246
- async abandonedFiles(beforeIso) {
1247
- return (await db.select().from(files).where(and(ne(files.state, "claimed"), lt(files.createdAt, new Date(beforeIso))))).map(toFileRecord);
1248
- },
1249
- async deleteFiles(ids) {
1250
- if (ids.length === 0) return;
1251
- await db.delete(files).where(inArray(files.id, [...ids]));
1252
- },
1253
- async webhooksForForm(formId) {
1254
- return (await db.select().from(webhooks).where(eq(webhooks.formId, formId))).map(toWebhookRecord);
1255
- },
1256
- async insertWebhook(record) {
1257
- await db.insert(webhooks).values({
1258
- id: record.id,
1259
- formId: record.formId,
1260
- url: record.url,
1261
- secret: record.secret,
1262
- consecutiveFailures: record.consecutiveFailures,
1263
- openedAt: record.openedAt === null ? null : new Date(record.openedAt)
1264
- });
1265
- },
1266
- async updateWebhook(record) {
1267
- await db.update(webhooks).set({
1268
- consecutiveFailures: record.consecutiveFailures,
1269
- openedAt: record.openedAt === null ? null : new Date(record.openedAt)
1270
- }).where(eq(webhooks.id, record.id));
1271
- },
1272
- async listWebhooks() {
1273
- return (await db.select().from(webhooks)).map(toWebhookRecord);
1274
- },
1275
- async deadDeliveries(limit) {
1276
- return (await db.select().from(deliveries).where(eq(deliveries.state, "dead")).orderBy(desc(deliveries.nextAttemptAt)).limit(limit)).map(toDeliveryRecord);
1277
- },
1278
- async getDelivery(id) {
1279
- const [row] = await db.select().from(deliveries).where(eq(deliveries.id, id)).limit(1);
1280
- return row === void 0 ? void 0 : toDeliveryRecord(row);
1281
- },
1282
- async claimDueDeliveries(nowIso, limit) {
1283
- return (await db.select().from(deliveries).where(and(eq(deliveries.state, "pending"), lte(deliveries.nextAttemptAt, new Date(nowIso)))).orderBy(deliveries.nextAttemptAt).limit(limit)).map(toDeliveryRecord);
1284
- },
1285
- async updateDelivery(record) {
1286
- await db.update(deliveries).set({
1287
- attempt: record.attempt,
1288
- nextAttemptAt: new Date(record.nextAttemptAt),
1289
- state: record.state,
1290
- lastError: record.lastError
1291
- }).where(eq(deliveries.id, record.id));
1292
- },
1293
- async listSubmissions() {
1294
- return (await db.select().from(submissions)).map(toSubmissionRecord);
1295
- },
1296
- async listSubmissionsByForm(formId) {
1297
- return (await db.select().from(submissions).where(eq(submissions.formId, formId)).orderBy(desc(submissions.submittedAt), desc(submissions.id))).map(toSubmissionRecord);
1298
- },
1299
- async listVersionsByForm(formId) {
1300
- return (await db.select().from(formVersions).where(eq(formVersions.formId, formId)).orderBy(desc(formVersions.version))).map((row) => ({
1301
- id: row.id,
1302
- formId: row.formId,
1303
- version: row.version,
1304
- schema: row.schema,
1305
- schemaHash: row.schemaHash
1306
- }));
1307
- },
1308
- async upsertDraft(record) {
1309
- await db.insert(drafts).values({
1310
- id: record.id,
1311
- formId: record.formId,
1312
- formVersionId: record.formVersionId,
1313
- data: record.data,
1314
- updatedAt: new Date(record.updatedAt)
1315
- }).onConflictDoUpdate({
1316
- target: [drafts.formId, drafts.id],
1317
- set: {
1318
- formVersionId: record.formVersionId,
1319
- data: record.data,
1320
- updatedAt: new Date(record.updatedAt)
1321
- }
1322
- });
1323
- },
1324
- async getDraft(formId, draftId) {
1325
- const row = (await db.select().from(drafts).where(and(eq(drafts.formId, formId), eq(drafts.id, draftId))).limit(1))[0];
1326
- if (row === void 0) return void 0;
1327
- return {
1328
- id: row.id,
1329
- formId: row.formId,
1330
- formVersionId: row.formVersionId,
1331
- data: row.data,
1332
- updatedAt: row.updatedAt.toISOString()
1333
- };
1334
- },
1335
- async insertUser(record) {
1336
- await db.insert(users).values({
1337
- id: record.id,
1338
- email: record.email,
1339
- passwordHash: record.passwordHash,
1340
- role: record.role,
1341
- createdAt: new Date(record.createdAt)
1342
- });
1343
- },
1344
- async getUserByEmail(email) {
1345
- const row = (await db.select().from(users).where(eq(users.email, email)).limit(1))[0];
1346
- if (row === void 0) return void 0;
1347
- return {
1348
- id: row.id,
1349
- email: row.email,
1350
- passwordHash: row.passwordHash,
1351
- role: row.role,
1352
- createdAt: row.createdAt.toISOString()
1353
- };
1354
- },
1355
- async insertApiKey(record) {
1356
- await db.insert(apiKeys).values({
1357
- id: record.id,
1358
- name: record.name,
1359
- prefix: record.prefix,
1360
- secretHash: record.secretHash,
1361
- role: record.role,
1362
- createdAt: new Date(record.createdAt),
1363
- revokedAt: record.revokedAt === null ? null : new Date(record.revokedAt)
1364
- });
1365
- },
1366
- async listApiKeys() {
1367
- return (await db.select().from(apiKeys)).map(toApiKeyRecord);
1368
- },
1369
- async findApiKeysByPrefix(prefix) {
1370
- return (await db.select().from(apiKeys).where(eq(apiKeys.prefix, prefix))).map(toApiKeyRecord);
1371
- },
1372
- async revokeApiKey(id, atIso) {
1373
- await db.update(apiKeys).set({ revokedAt: new Date(atIso) }).where(eq(apiKeys.id, id));
1374
- }
1375
- };
1376
- }
1377
- function toApiKeyRecord(row) {
1378
- return {
1379
- id: row.id,
1380
- name: row.name,
1381
- prefix: row.prefix,
1382
- secretHash: row.secretHash,
1383
- role: row.role,
1384
- createdAt: row.createdAt.toISOString(),
1385
- revokedAt: row.revokedAt === null ? null : row.revokedAt.toISOString()
1386
- };
1387
- }
1388
- function toSubmissionRecord(row) {
1389
- return {
1390
- id: row.id,
1391
- formId: row.formId,
1392
- formVersionId: row.formVersionId,
1393
- data: row.data,
1394
- submittedAt: row.submittedAt.toISOString()
1395
- };
1396
- }
1397
- /** A delivery row as the port describes it. */
1398
- function toDeliveryRecord(row) {
1399
- return {
1400
- id: row.id,
1401
- webhookId: row.webhookId,
1402
- submissionId: row.submissionId,
1403
- eventId: row.eventId,
1404
- body: row.body,
1405
- attempt: row.attempt,
1406
- nextAttemptAt: row.nextAttemptAt.toISOString(),
1407
- state: row.state,
1408
- lastError: row.lastError
1409
- };
1410
- }
1411
- /** A files row as the port describes it: timestamps as ISO strings. */
1412
- function toFileRecord(row) {
1413
- return {
1414
- id: row.id,
1415
- formId: row.formId,
1416
- name: row.name,
1417
- size: row.size,
1418
- contentType: row.contentType,
1419
- storageKey: row.storageKey,
1420
- state: row.state,
1421
- createdAt: row.createdAt.toISOString(),
1422
- submissionId: row.submissionId
1423
- };
1424
- }
1425
- /** A webhook row as the port describes it, health included. */
1426
- function toWebhookRecord(row) {
1427
- return {
1428
- id: row.id,
1429
- formId: row.formId,
1430
- url: row.url,
1431
- secret: row.secret,
1432
- consecutiveFailures: row.consecutiveFailures,
1433
- openedAt: row.openedAt === null ? null : row.openedAt.toISOString()
1434
- };
1435
- }
1436
- /**
1437
- * An entry as the table holds it.
1438
- *
1439
- * Absent and null are the same thing here, and the conversion is in one place
1440
- * so two call sites cannot disagree about which they write.
1441
- */
1442
- function auditRow(entry) {
1443
- return {
1444
- id: entry.id,
1445
- at: new Date(entry.at),
1446
- action: entry.action,
1447
- actorKind: entry.actorKind ?? null,
1448
- actorId: entry.actorId ?? null,
1449
- subject: entry.subject ?? null,
1450
- requestId: entry.requestId ?? null,
1451
- detail: entry.detail ?? null
1452
- };
1453
- }
1454
- //#endregion
1455
- //#region src/file-store.ts
1456
- /**
1457
- * Bytes under one directory, one file per key.
1458
- *
1459
- * **Keys are checked, not trusted.** A key is minted by the server from a form
1460
- * id and a file id, so it cannot contain a traversal — but this resolves it
1461
- * and refuses anything landing outside the root regardless. A path check that
1462
- * only runs when the input is untrusted is a path check that stops running the
1463
- * day somebody adds a caller.
1464
- */
1465
- function createLocalFileStore(root) {
1466
- const base = resolve(root);
1467
- const pathFor = (key) => {
1468
- if (key.includes("\\")) throw new Error(`Refusing a storage key that could resolve outside the store: ${key}`);
1469
- const full = resolve(join(base, key));
1470
- if (full !== base && !full.startsWith(base + sep)) throw new Error(`Refusing a storage key that resolves outside the store: ${key}`);
1471
- return full;
1472
- };
1473
- return {
1474
- async put(key, bytes) {
1475
- const full = pathFor(key);
1476
- await mkdir(dirname(full), { recursive: true });
1477
- await writeFile(full, bytes);
1478
- },
1479
- async open(key) {
1480
- const full = pathFor(key);
1481
- try {
1482
- await stat(full);
1483
- } catch {
1484
- return;
1485
- }
1486
- return createReadStream(full);
1487
- },
1488
- async remove(key) {
1489
- await rm(pathFor(key), { force: true });
1490
- },
1491
- async sizeOf(key) {
1492
- try {
1493
- return (await stat(pathFor(key))).size;
1494
- } catch {
1495
- return;
1496
- }
1497
- }
1498
- };
1499
- }
1500
- /**
1501
- * The checksum a receiver can compare against.
1502
- *
1503
- * Returned when the bytes are stored and never asked of the client: a checksum
1504
- * the uploader computed is a checksum the uploader can lie about, so this one
1505
- * is only useful for telling a *later* reader that the bytes have not changed.
1506
- */
1507
- function sha256(bytes) {
1508
- return createHash("sha256").update(bytes).digest("hex");
1509
- }
1510
- //#endregion
1511
- //#region src/file-collector.ts
1512
- function startFileCollector(storage, store, options = {}) {
1513
- const interval = options.intervalMs ?? 9e5;
1514
- const afterHours = options.afterHours ?? 24;
1515
- let stopped = false;
1516
- let running = false;
1517
- const tick = async () => {
1518
- if (running || stopped) return;
1519
- running = true;
1520
- try {
1521
- const abandoned = await collectAbandonedFiles(storage, /* @__PURE__ */ new Date(), afterHours);
1522
- if (abandoned.length === 0) return;
1523
- const removed = [];
1524
- for (const file of abandoned) {
1525
- await store.remove(file.storageKey);
1526
- removed.push(file.id);
1527
- }
1528
- await storage.deleteFiles(removed);
1529
- } catch (error) {
1530
- console.error("files: a collection pass failed", error);
1531
- } finally {
1532
- running = false;
1533
- }
1534
- };
1535
- const timer = setInterval(() => void tick(), interval);
1536
- timer.unref?.();
1537
- return { stop: () => {
1538
- stopped = true;
1539
- clearInterval(timer);
1540
- } };
1541
- }
1542
- //#endregion
1543
- export { bootstrapSchema as a, createPostgresStorage as i, createLocalFileStore as n, createApp as o, sha256 as r, SCHEMA_HASH_HEADER as s, startFileCollector as t };
1544
-
1545
- //# sourceMappingURL=file-collector-CGSJAtGO.mjs.map