@formancy/server 0.2.0 → 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.
@@ -1,7 +1,7 @@
1
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";
2
3
  import Fastify from "fastify";
3
4
  import rateLimit from "@fastify/rate-limit";
4
- import { auditedBy, authenticateApiKey, authenticateLocal, can, collectAbandonedFiles, createApiKey, createLocalUser, createSubmission, decodeSolution, exportCsv, healthOf, listForms, listSubmissions, listVersions, mintChallenge, offerUpload, publishForm, replayDelivery, resolveForm, resumeDraft, saveDraft, setFormAccess, startDraft, verifySolution } from "@formancy/server-core";
5
5
  import { hash, verify } from "@node-rs/argon2";
6
6
  import { SignJWT, jwtVerify } from "jose";
7
7
  import { and, desc, eq, inArray, lt, lte, max, ne } from "drizzle-orm";
@@ -10,6 +10,216 @@ import { integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-c
10
10
  import { createReadStream } from "node:fs";
11
11
  import { mkdir, rm, stat, writeFile } from "node:fs/promises";
12
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
13
223
  //#region src/auth-runtime.ts
14
224
  /**
15
225
  * The real cryptography, confined to the composition root: argon2id for
@@ -67,7 +277,6 @@ function createSessionTokens(secret) {
67
277
  }
68
278
  //#endregion
69
279
  //#region src/app.ts
70
- const SCHEMA_HASH_HEADER = "x-formancy-schema-hash";
71
280
  /** Base64 JSON, the shape an ALTCHA client already produces. */
72
281
  const CHALLENGE_HEADER = "x-formancy-challenge";
73
282
  /** Proves the bearer started this draft. Lower-case: Fastify normalises. */
@@ -121,6 +330,7 @@ async function createApp(storage, options) {
121
330
  nowIso: () => (/* @__PURE__ */ new Date()).toISOString(),
122
331
  draftSecret: options.authSecret,
123
332
  ...options.optionsSources === void 0 ? {} : { optionsSources: options.optionsSources },
333
+ ...options.scanner === void 0 ? {} : { scanner: options.scanner },
124
334
  capabilities: {
125
335
  now: () => Date.now(),
126
336
  today: () => (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
@@ -261,39 +471,9 @@ async function createApp(storage, options) {
261
471
  secret: outcome.secret
262
472
  });
263
473
  });
264
- app.post("/forms", { preHandler: requires("form.publish") }, async (request, reply) => {
265
- const body = request.body;
266
- if (body === null || typeof body.path !== "string" || body.path === "" || body.schema === void 0) return reply.code(400).send({
267
- error: "bad_request",
268
- message: "Body needs { path, schema }."
269
- });
270
- const outcome = await publishForm(deps, {
271
- path: body.path,
272
- schema: body.schema,
273
- actor: request.actor
274
- });
275
- if (!outcome.ok) switch (outcome.kind) {
276
- case "invalid_schema": return reply.code(422).send({
277
- error: outcome.kind,
278
- errors: outcome.errors
279
- });
280
- case "unsafe_pattern": return reply.code(422).send({
281
- error: outcome.kind,
282
- patterns: outcome.patterns
283
- });
284
- case "invalid_logic": return reply.code(422).send({
285
- error: outcome.kind,
286
- message: outcome.message
287
- });
288
- case "unknown_options_source": return reply.code(422).send({
289
- error: outcome.kind,
290
- sources: outcome.sources
291
- });
292
- }
293
- return reply.code(201).send({
294
- version: outcome.version,
295
- schemaHash: outcome.schemaHash
296
- });
474
+ await app.register(publishRoutes, {
475
+ deps,
476
+ requires
297
477
  });
298
478
  app.put("/f/:path/access", { preHandler: requires("form.publish") }, async (request, reply) => {
299
479
  const { path } = request.params;
@@ -548,111 +728,12 @@ async function createApp(storage, options) {
548
728
  if (resumed === void 0) return reply.code(404).send({ error: "unknown_draft" });
549
729
  return reply.send(resumed);
550
730
  });
551
- /**
552
- * Ask where to put a file.
553
- *
554
- * Refusing here, before a byte is sent, is the difference between rejecting
555
- * a 2 GB upload and receiving one first. The reply carries the id the
556
- * submission will reference and the URL to PUT the bytes to.
557
- */
558
- app.post("/f/:path/files", { config: { rateLimit: {
559
- max: submissionLimit.max,
560
- timeWindow: submissionLimit.timeWindowMs
561
- } } }, async (request, reply) => {
562
- if (fileStore === void 0) return reply.code(501).send({
563
- error: "uploads_unavailable",
564
- message: "This deployment has no file store configured, so it cannot accept uploads."
565
- });
566
- const { path } = request.params;
567
- const resolved = await resolveForm(deps, path);
568
- if (resolved === void 0) return reply.code(404).send({ error: "unknown_form" });
569
- const form = await storage.getFormByPath(path);
570
- if (form === void 0) return reply.code(404).send({ error: "unknown_form" });
571
- const actor = await actorOf(request);
572
- const origin = request.headers.origin;
573
- const body = request.body ?? {};
574
- const field = typeof body.field === "string" ? body.field : void 0;
575
- const name = typeof body.name === "string" ? body.name : void 0;
576
- const size = typeof body.size === "number" ? body.size : void 0;
577
- const contentType = typeof body.contentType === "string" ? body.contentType : "application/octet-stream";
578
- if (field === void 0 || name === void 0 || size === void 0) return reply.code(400).send({
579
- error: "invalid_request",
580
- message: "Send the field, the name and the size of the file you want to upload."
581
- });
582
- if (size > maxFileBytes) return reply.code(413).send({
583
- error: "too_large",
584
- message: `This deployment accepts files up to ${String(maxFileBytes)} bytes.`
585
- });
586
- const outcome = await offerUpload({
587
- storage,
588
- now: () => /* @__PURE__ */ new Date(),
589
- newId: () => randomUUID()
590
- }, {
591
- schema: resolved.schema,
592
- form,
593
- actor: actor === void 0 ? "anonymous" : "authenticated",
594
- origin: typeof origin === "string" ? origin : void 0,
595
- field,
596
- name,
597
- size,
598
- contentType
599
- });
600
- if (!outcome.ok) {
601
- const status = outcome.reason === "not_allowed" ? 403 : outcome.reason === "too_large" ? 413 : 400;
602
- return reply.code(status).send({ error: outcome.reason });
603
- }
604
- return reply.code(201).send({
605
- id: outcome.file.id,
606
- name: outcome.file.name,
607
- size: outcome.file.size,
608
- contentType: outcome.file.contentType,
609
- storageKey: outcome.file.storageKey,
610
- uploadUrl: `/f/${encodeURIComponent(path)}/files/${outcome.file.id}`
611
- });
612
- });
613
- /** Receive the bytes for a file that was offered. */
614
- app.put("/f/:path/files/:id", async (request, reply) => {
615
- if (fileStore === void 0) return reply.code(501).send({ error: "uploads_unavailable" });
616
- const { path, id } = request.params;
617
- const form = await storage.getFormByPath(path);
618
- if (form === void 0) return reply.code(404).send({ error: "unknown_form" });
619
- const file = await storage.getFile(id);
620
- if (file === void 0 || file.formId !== form.id) return reply.code(404).send({ error: "unknown_file" });
621
- if (file.state !== "offered") return reply.code(409).send({ error: "already_uploaded" });
622
- const bytes = request.body;
623
- if (!Buffer.isBuffer(bytes)) return reply.code(400).send({ error: "no_body" });
624
- if (bytes.byteLength > maxFileBytes) return reply.code(413).send({ error: "too_large" });
625
- if (bytes.byteLength !== file.size) return reply.code(400).send({
626
- error: "size_mismatch",
627
- message: `This upload was offered as ${String(file.size)} bytes and ${String(bytes.byteLength)} arrived.`
628
- });
629
- await fileStore.put(file.storageKey, bytes);
630
- await storage.updateFile({
631
- ...file,
632
- state: "stored"
633
- });
634
- return reply.code(204).send();
635
- });
636
- /**
637
- * Serve a file back.
638
- *
639
- * Always as an attachment, never inline, and `nosniff` alongside it. Stored
640
- * cross-site scripting through an uploaded HTML or SVG file is the most
641
- * commonly exploited vulnerability in this product category, and the real
642
- * answer is a separate hostname — which a single-container deployment does
643
- * not have. Forcing a download is the accommodation, and it is the one place
644
- * this deployment model genuinely costs something.
645
- */
646
- app.get("/f/:path/files/:id", async (request, reply) => {
647
- if (fileStore === void 0) return reply.code(501).send({ error: "uploads_unavailable" });
648
- const { path, id } = request.params;
649
- if (await actorOf(request) === void 0) return reply.code(401).send({ error: "unauthenticated" });
650
- const form = await storage.getFormByPath(path);
651
- const file = await storage.getFile(id);
652
- if (form === void 0 || file === void 0 || file.formId !== form.id) return reply.code(404).send({ error: "unknown_file" });
653
- const stream = await fileStore.open(file.storageKey);
654
- if (stream === void 0) return reply.code(404).send({ error: "unknown_file" });
655
- 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);
731
+ await app.register(fileRoutes, {
732
+ deps,
733
+ fileStore,
734
+ maxFileBytes,
735
+ limit: submissionLimit,
736
+ actorOf
656
737
  });
657
738
  app.post("/f/:path/submissions", { config: { rateLimit: {
658
739
  max: submissionLimit.max,
@@ -1459,6 +1540,6 @@ function startFileCollector(storage, store, options = {}) {
1459
1540
  } };
1460
1541
  }
1461
1542
  //#endregion
1462
- export { bootstrapSchema as a, createPostgresStorage as i, createLocalFileStore as n, SCHEMA_HASH_HEADER as o, sha256 as r, createApp as s, startFileCollector as t };
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 };
1463
1544
 
1464
- //# sourceMappingURL=file-collector-WWc-eo8h.mjs.map
1545
+ //# sourceMappingURL=file-collector-CGSJAtGO.mjs.map