@cogenta/forms 0.2.4

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.
Files changed (54) hide show
  1. package/LICENSE +373 -0
  2. package/dist/anti-abuse.d.ts +16 -0
  3. package/dist/anti-abuse.d.ts.map +1 -0
  4. package/dist/anti-abuse.js +58 -0
  5. package/dist/anti-abuse.js.map +1 -0
  6. package/dist/captcha.d.ts +18 -0
  7. package/dist/captcha.d.ts.map +1 -0
  8. package/dist/captcha.js +69 -0
  9. package/dist/captcha.js.map +1 -0
  10. package/dist/conditions.d.ts +6 -0
  11. package/dist/conditions.d.ts.map +1 -0
  12. package/dist/conditions.js +52 -0
  13. package/dist/conditions.js.map +1 -0
  14. package/dist/csv.d.ts +10 -0
  15. package/dist/csv.d.ts.map +1 -0
  16. package/dist/csv.js +56 -0
  17. package/dist/csv.js.map +1 -0
  18. package/dist/file-field.d.ts +64 -0
  19. package/dist/file-field.d.ts.map +1 -0
  20. package/dist/file-field.js +169 -0
  21. package/dist/file-field.js.map +1 -0
  22. package/dist/index.d.ts +18 -0
  23. package/dist/index.d.ts.map +1 -0
  24. package/dist/index.js +12 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/ip.d.ts +12 -0
  27. package/dist/ip.d.ts.map +1 -0
  28. package/dist/ip.js +15 -0
  29. package/dist/ip.js.map +1 -0
  30. package/dist/notify.d.ts +52 -0
  31. package/dist/notify.d.ts.map +1 -0
  32. package/dist/notify.js +113 -0
  33. package/dist/notify.js.map +1 -0
  34. package/dist/rows.d.ts +13 -0
  35. package/dist/rows.d.ts.map +1 -0
  36. package/dist/rows.js +72 -0
  37. package/dist/rows.js.map +1 -0
  38. package/dist/store.d.ts +71 -0
  39. package/dist/store.d.ts.map +1 -0
  40. package/dist/store.js +509 -0
  41. package/dist/store.js.map +1 -0
  42. package/dist/tables.d.ts +21 -0
  43. package/dist/tables.d.ts.map +1 -0
  44. package/dist/tables.js +131 -0
  45. package/dist/tables.js.map +1 -0
  46. package/dist/types.d.ts +181 -0
  47. package/dist/types.d.ts.map +1 -0
  48. package/dist/types.js +56 -0
  49. package/dist/types.js.map +1 -0
  50. package/dist/validate.d.ts +20 -0
  51. package/dist/validate.d.ts.map +1 -0
  52. package/dist/validate.js +353 -0
  53. package/dist/validate.js.map +1 -0
  54. package/package.json +43 -0
package/dist/csv.js ADDED
@@ -0,0 +1,56 @@
1
+ import { isFormFileValue } from './types.js';
2
+ /**
3
+ * Fiche 47 task 9 — the server-streamed CSV export. Deliberately mirrors
4
+ * `packages/admin/src/lib/csv.ts` byte for byte rather than importing it (an
5
+ * admin package is a browser bundle with React in its dependency tree;
6
+ * `@cogenta/forms` is plain Node/ESM and must stay that way) — including the
7
+ * CWE-1236 formula-injection guard, which is the one property a
8
+ * non-regression test (fiche 47 task 9's own requirement) checks survives
9
+ * the move from "build the whole CSV client-side" to "stream it from the
10
+ * server". Any change here must be mirrored there, and vice versa.
11
+ */
12
+ const FORMULA_LEADING_CHARS = new Set(['=', '+', '-', '@']);
13
+ export function csvField(value) {
14
+ const guarded = FORMULA_LEADING_CHARS.has(value.charAt(0)) ? `'${value}` : value;
15
+ if (/[",\n\r]/u.test(guarded)) {
16
+ return `"${guarded.replace(/"/gu, '""')}"`;
17
+ }
18
+ return guarded;
19
+ }
20
+ export function toCsvRow(values) {
21
+ return `${values.map(csvField).join(',')}\r\n`;
22
+ }
23
+ function valueText(value) {
24
+ if (value === undefined)
25
+ return '';
26
+ if (Array.isArray(value))
27
+ return value.join(', ');
28
+ if (isFormFileValue(value))
29
+ return value.filename;
30
+ return String(value);
31
+ }
32
+ /** The full set of value column names across a page of submissions, in first-seen order — same approach `form-submissions.tsx`'s client-side export already uses. */
33
+ export function csvValueColumns(submissions) {
34
+ const seen = new Set();
35
+ for (const submission of submissions) {
36
+ for (const key of Object.keys(submission.values))
37
+ seen.add(key);
38
+ }
39
+ return [...seen];
40
+ }
41
+ export function csvHeaderRow(valueColumns) {
42
+ return toCsvRow(['id', 'form', 'status', 'submittedAt', 'referrer', ...valueColumns]);
43
+ }
44
+ export function csvSubmissionRow(submission, valueColumns) {
45
+ return toCsvRow([
46
+ submission.id,
47
+ submission.formName,
48
+ submission.status,
49
+ submission.submittedAt,
50
+ submission.referrer ?? '',
51
+ ...valueColumns.map((column) => valueText(submission.values[column])),
52
+ ]);
53
+ }
54
+ /** UTF-8 BOM, so Excel opens accented characters correctly — same reason `admin/src/lib/csv.ts`'s `downloadCsv` prepends one. */
55
+ export const CSV_BOM = '';
56
+ //# sourceMappingURL=csv.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"csv.js","sourceRoot":"","sources":["../src/csv.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAE5C;;;;;;;;;GASG;AAEH,MAAM,qBAAqB,GAAG,IAAI,GAAG,CAAC,CAAC,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,CAAC,CAAC,CAAA;AAE3D,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,MAAM,OAAO,GAAG,qBAAqB,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAA;IAChF,IAAI,WAAW,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;QAC9B,OAAO,IAAI,OAAO,CAAC,OAAO,CAAC,KAAK,EAAE,IAAI,CAAC,GAAG,CAAA;IAC5C,CAAC;IACD,OAAO,OAAO,CAAA;AAChB,CAAC;AAED,MAAM,UAAU,QAAQ,CAAC,MAAyB;IAChD,OAAO,GAAG,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAA;AAChD,CAAC;AAED,SAAS,SAAS,CAAC,KAA6C;IAC9D,IAAI,KAAK,KAAK,SAAS;QAAE,OAAO,EAAE,CAAA;IAClC,IAAI,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IACjD,IAAI,eAAe,CAAC,KAAK,CAAC;QAAE,OAAO,KAAK,CAAC,QAAQ,CAAA;IACjD,OAAO,MAAM,CAAC,KAAK,CAAC,CAAA;AACtB,CAAC;AAED,qKAAqK;AACrK,MAAM,UAAU,eAAe,CAAC,WAAsC;IACpE,MAAM,IAAI,GAAG,IAAI,GAAG,EAAU,CAAA;IAC9B,KAAK,MAAM,UAAU,IAAI,WAAW,EAAE,CAAC;QACrC,KAAK,MAAM,GAAG,IAAI,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,MAAM,CAAC;YAAE,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,CAAA;IACjE,CAAC;IACD,OAAO,CAAC,GAAG,IAAI,CAAC,CAAA;AAClB,CAAC;AAED,MAAM,UAAU,YAAY,CAAC,YAA+B;IAC1D,OAAO,QAAQ,CAAC,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,EAAE,UAAU,EAAE,GAAG,YAAY,CAAC,CAAC,CAAA;AACvF,CAAC;AAED,MAAM,UAAU,gBAAgB,CAC9B,UAA0B,EAC1B,YAA+B;IAE/B,OAAO,QAAQ,CAAC;QACd,UAAU,CAAC,EAAE;QACb,UAAU,CAAC,QAAQ;QACnB,UAAU,CAAC,MAAM;QACjB,UAAU,CAAC,WAAW;QACtB,UAAU,CAAC,QAAQ,IAAI,EAAE;QACzB,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC,MAAM,EAAE,EAAE,CAC7B,SAAS,CAAC,UAAU,CAAC,MAAM,CAAC,MAAM,CAA2C,CAAC,CAC/E;KACF,CAAC,CAAA;AACJ,CAAC;AAED,iIAAiI;AACjI,MAAM,CAAC,MAAM,OAAO,GAAG,GAAG,CAAA"}
@@ -0,0 +1,64 @@
1
+ import { type FormFieldDefinition, type FormFileCategory, type FormFileValue } from './types.js';
2
+ /**
3
+ * Fiche 47 task 3 — the `file` field's byte-level defence. Reuses the same
4
+ * discipline as the media pipeline's `verifyRealType` (`@cogenta/api`'s
5
+ * `media-router.ts`, L10 task 5): the category is read from the bytes,
6
+ * *never* from a filename or a declared `Content-Type` — both are
7
+ * attacker-controlled, and this route is reached by an anonymous visitor
8
+ * (fiche 47's own framing: "traite ça comme une vraie surface d'attaque").
9
+ *
10
+ * `sniffImageFormat` itself is imported straight from `@cogenta/core`
11
+ * (already a direct dependency) rather than duplicated; the three other
12
+ * categories are small, zero-dependency signature checks in the same spirit
13
+ * as `@cogenta/core`'s own `format-sniff.ts`.
14
+ */
15
+ /** A hard ceiling regardless of what a form's own `maxSizeBytes` (or this default) says — an operator cannot accidentally turn an anonymous upload route into an unbounded disk sink. */
16
+ export declare const FORM_FILE_HARD_MAX_BYTES: number;
17
+ export declare const DEFAULT_FORM_FILE_MAX_BYTES: number;
18
+ /** The category these bytes actually are, or `null` when nothing this route accepts recognises them. */
19
+ export declare function sniffFormFileCategory(bytes: Uint8Array): FormFileCategory | null;
20
+ /** The content type this category's bytes earn — never the uploader's declared `Content-Type`, same reasoning as `verifyRealType` in `media-router.ts`. */
21
+ export declare function contentTypeForCategory(category: FormFileCategory): string;
22
+ /**
23
+ * Throws `FORM_FILE_REJECTED` when `bytes` do not belong to a category this
24
+ * field accepts, or exceed the size ceiling. Returns the sniffed category on
25
+ * success — the caller (the router, which alone holds a `StorageDriver`)
26
+ * still has to actually store the bytes.
27
+ */
28
+ export declare function assertAllowedFormFile(field: FormFieldDefinition, bytes: Uint8Array): FormFileCategory;
29
+ /**
30
+ * Which field a signed token is good for — a second security-review pass on
31
+ * this exact scheme found that without this, a token legitimately issued
32
+ * for a real upload on one field (say, an `image`-only "photo" field) could
33
+ * be replayed as the value of an *unrelated* `file` field on the same or a
34
+ * different form (say, a `pdf`-only "attachment" field): the signature was
35
+ * valid, so `resolveFileFields` accepted it without re-checking that
36
+ * field's own `acceptCategories`. Binding the signature to `formId` and
37
+ * `fieldName` (mixed into what is hashed, not just embedded in the JSON
38
+ * payload a client could otherwise edit) makes a token useless anywhere
39
+ * but the exact field it was issued for.
40
+ */
41
+ export interface FormFileTokenContext {
42
+ readonly formId: string;
43
+ readonly fieldName: string;
44
+ }
45
+ /**
46
+ * A `FormFileValue` carried forward across a multi-step form's pages
47
+ * (`_accumulated`) is client-supplied text like everything else in that
48
+ * blob — a security review of this exact file field found that trusting its
49
+ * *shape* alone (`isFormFileValue`) let an anonymous visitor hand-craft one,
50
+ * claiming any `storageKey` exists, without ever uploading a real byte.
51
+ * Signing with the site's own derived secret (never a value stored
52
+ * anywhere else, never re-derivable by a client — `@cogenta/cli`'s
53
+ * `serve.ts` derives it from `COGENTA_AUTH_SIGNING_KEY`, the same "derived,
54
+ * never a second secret" discipline `commentsIpHashSecret` already
55
+ * follows) is what turns "a client can carry this forward" into "a client
56
+ * cannot forge or edit this": the router's own `resolveFileFields` never
57
+ * accepts a raw carried-forward object again, only a token this function
58
+ * itself produced. See `FormFileTokenContext`'s own doc for why the token
59
+ * is additionally scoped to the exact form and field it was issued for.
60
+ */
61
+ export declare function signFormFileToken(secret: string, context: FormFileTokenContext, value: FormFileValue): string;
62
+ /** `null` for a missing/malformed/mis-signed/wrong-field token — the caller treats that exactly like the field being absent, never like a shape error worth a different message (nothing about *why* a forged token failed should be observable). */
63
+ export declare function verifyFormFileToken(secret: string, context: FormFileTokenContext, token: string): FormFileValue | null;
64
+ //# sourceMappingURL=file-field.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-field.d.ts","sourceRoot":"","sources":["../src/file-field.ts"],"names":[],"mappings":"AAEA,OAAO,EAEL,KAAK,mBAAmB,EACxB,KAAK,gBAAgB,EACrB,KAAK,aAAa,EAEnB,MAAM,YAAY,CAAA;AAEnB;;;;;;;;;;;;GAYG;AAEH,yLAAyL;AACzL,eAAO,MAAM,wBAAwB,QAAmB,CAAA;AACxD,eAAO,MAAM,2BAA2B,QAAmB,CAAA;AAiD3D,wGAAwG;AACxG,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,UAAU,GAAG,gBAAgB,GAAG,IAAI,CAMhF;AASD,2JAA2J;AAC3J,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,gBAAgB,GAAG,MAAM,CAEzE;AAWD;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACnC,KAAK,EAAE,mBAAmB,EAC1B,KAAK,EAAE,UAAU,GAChB,gBAAgB,CA6BlB;AAED;;;;;;;;;;;GAWG;AACH,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAA;CAC3B;AAMD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,oBAAoB,EAC7B,KAAK,EAAE,aAAa,GACnB,MAAM,CAMR;AAED,qPAAqP;AACrP,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,oBAAoB,EAC7B,KAAK,EAAE,MAAM,GACZ,aAAa,GAAG,IAAI,CAmBtB"}
@@ -0,0 +1,169 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { CogentaError, describeContainer, sniffImageFormat } from '@cogenta/core';
3
+ import { FORM_FILE_CATEGORIES, isFormFileValue, } from './types.js';
4
+ /**
5
+ * Fiche 47 task 3 — the `file` field's byte-level defence. Reuses the same
6
+ * discipline as the media pipeline's `verifyRealType` (`@cogenta/api`'s
7
+ * `media-router.ts`, L10 task 5): the category is read from the bytes,
8
+ * *never* from a filename or a declared `Content-Type` — both are
9
+ * attacker-controlled, and this route is reached by an anonymous visitor
10
+ * (fiche 47's own framing: "traite ça comme une vraie surface d'attaque").
11
+ *
12
+ * `sniffImageFormat` itself is imported straight from `@cogenta/core`
13
+ * (already a direct dependency) rather than duplicated; the three other
14
+ * categories are small, zero-dependency signature checks in the same spirit
15
+ * as `@cogenta/core`'s own `format-sniff.ts`.
16
+ */
17
+ /** A hard ceiling regardless of what a form's own `maxSizeBytes` (or this default) says — an operator cannot accidentally turn an anonymous upload route into an unbounded disk sink. */
18
+ export const FORM_FILE_HARD_MAX_BYTES = 25 * 1024 * 1024;
19
+ export const DEFAULT_FORM_FILE_MAX_BYTES = 10 * 1024 * 1024;
20
+ const PDF_SIGNATURE = [0x25, 0x50, 0x44, 0x46]; // "%PDF"
21
+ // A ZIP local-file-header — what a .docx/.xlsx/.pptx/.odt *is*: an office
22
+ // document is never distinguished further than "a ZIP archive with the
23
+ // shape Office/OpenDocument containers share" — enough to refuse a renamed
24
+ // executable without pretending to fully parse the container.
25
+ const ZIP_SIGNATURES = [
26
+ [0x50, 0x4b, 0x03, 0x04],
27
+ [0x50, 0x4b, 0x05, 0x06],
28
+ [0x50, 0x4b, 0x07, 0x08],
29
+ ];
30
+ function startsWithBytes(bytes, signature) {
31
+ if (bytes.length < signature.length)
32
+ return false;
33
+ return signature.every((byte, index) => bytes[index] === byte);
34
+ }
35
+ /**
36
+ * A conservative "this looks like text a human wrote" heuristic: no NUL byte
37
+ * (binary formats almost always have one in their first kilobyte) and at
38
+ * least 95% of the sampled bytes are printable ASCII, a tab, or part of a
39
+ * line ending. Not a real encoding detector — it does not need to be one, it
40
+ * only needs to keep out disguised binaries, and a `text`-category field is
41
+ * never executed or served as anything but a downloadable attachment.
42
+ */
43
+ function looksLikePlainText(bytes) {
44
+ if (bytes.length === 0)
45
+ return false;
46
+ const sample = bytes.subarray(0, Math.min(bytes.length, 4096));
47
+ let printable = 0;
48
+ for (const byte of sample) {
49
+ if (byte === 0x00)
50
+ return false;
51
+ if (byte === 0x09 || byte === 0x0a || byte === 0x0d) {
52
+ printable += 1;
53
+ continue;
54
+ }
55
+ if (byte >= 0x20 && byte <= 0x7e) {
56
+ printable += 1;
57
+ continue;
58
+ }
59
+ // Allow UTF-8 continuation/lead bytes (accented text, BOM) without
60
+ // counting them against the printable ratio — but do not count them
61
+ // *for* it either, since a genuinely binary file can also contain runs
62
+ // of high bytes.
63
+ if (byte >= 0xc0)
64
+ continue;
65
+ }
66
+ return printable / sample.length >= 0.95;
67
+ }
68
+ /** The category these bytes actually are, or `null` when nothing this route accepts recognises them. */
69
+ export function sniffFormFileCategory(bytes) {
70
+ if (sniffImageFormat(bytes) !== null)
71
+ return 'image';
72
+ if (startsWithBytes(bytes, PDF_SIGNATURE))
73
+ return 'pdf';
74
+ if (ZIP_SIGNATURES.some((signature) => startsWithBytes(bytes, signature)))
75
+ return 'document';
76
+ if (looksLikePlainText(bytes))
77
+ return 'text';
78
+ return null;
79
+ }
80
+ const CONTENT_TYPE_BY_CATEGORY = {
81
+ image: 'application/octet-stream', // refined by `sniffImageFormat`'s own format when the caller wants it; a form attachment is never inlined as `<img>`, so the generic type is enough here.
82
+ pdf: 'application/pdf',
83
+ document: 'application/octet-stream',
84
+ text: 'text/plain; charset=utf-8',
85
+ };
86
+ /** The content type this category's bytes earn — never the uploader's declared `Content-Type`, same reasoning as `verifyRealType` in `media-router.ts`. */
87
+ export function contentTypeForCategory(category) {
88
+ return CONTENT_TYPE_BY_CATEGORY[category];
89
+ }
90
+ function rejected(field, reason) {
91
+ return new CogentaError({
92
+ code: 'FORM_FILE_REJECTED',
93
+ message: reason,
94
+ hint: 'Upload a file whose real content matches one of the types this field accepts.',
95
+ details: { field: field.name },
96
+ });
97
+ }
98
+ /**
99
+ * Throws `FORM_FILE_REJECTED` when `bytes` do not belong to a category this
100
+ * field accepts, or exceed the size ceiling. Returns the sniffed category on
101
+ * success — the caller (the router, which alone holds a `StorageDriver`)
102
+ * still has to actually store the bytes.
103
+ */
104
+ export function assertAllowedFormFile(field, bytes) {
105
+ const maxBytes = Math.min(field.maxSizeBytes ?? DEFAULT_FORM_FILE_MAX_BYTES, FORM_FILE_HARD_MAX_BYTES);
106
+ if (bytes.length === 0)
107
+ throw rejected(field, `"${field.name}" needs a real file, not an empty one.`);
108
+ if (bytes.length > maxBytes) {
109
+ throw rejected(field, `"${field.name}" is larger than the ${Math.floor(maxBytes / (1024 * 1024))}MB this form accepts.`);
110
+ }
111
+ const category = sniffFormFileCategory(bytes);
112
+ const accepted = field.acceptCategories ?? FORM_FILE_CATEGORIES;
113
+ if (category === null) {
114
+ throw rejected(field, `"${field.name}" was sent as ${describeContainer(bytes)}, which is not a supported file type (accepted: ${accepted.join(', ')}).`);
115
+ }
116
+ if (!accepted.includes(category)) {
117
+ throw rejected(field, `"${field.name}" was sent as a ${category} file, which this field does not accept (accepted: ${accepted.join(', ')}).`);
118
+ }
119
+ return category;
120
+ }
121
+ function scopedPayload(context, payload) {
122
+ return `${context.formId}:${context.fieldName}:${payload}`;
123
+ }
124
+ /**
125
+ * A `FormFileValue` carried forward across a multi-step form's pages
126
+ * (`_accumulated`) is client-supplied text like everything else in that
127
+ * blob — a security review of this exact file field found that trusting its
128
+ * *shape* alone (`isFormFileValue`) let an anonymous visitor hand-craft one,
129
+ * claiming any `storageKey` exists, without ever uploading a real byte.
130
+ * Signing with the site's own derived secret (never a value stored
131
+ * anywhere else, never re-derivable by a client — `@cogenta/cli`'s
132
+ * `serve.ts` derives it from `COGENTA_AUTH_SIGNING_KEY`, the same "derived,
133
+ * never a second secret" discipline `commentsIpHashSecret` already
134
+ * follows) is what turns "a client can carry this forward" into "a client
135
+ * cannot forge or edit this": the router's own `resolveFileFields` never
136
+ * accepts a raw carried-forward object again, only a token this function
137
+ * itself produced. See `FormFileTokenContext`'s own doc for why the token
138
+ * is additionally scoped to the exact form and field it was issued for.
139
+ */
140
+ export function signFormFileToken(secret, context, value) {
141
+ const payload = Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
142
+ const signature = createHmac('sha256', secret)
143
+ .update(scopedPayload(context, payload))
144
+ .digest('base64url');
145
+ return `${payload}.${signature}`;
146
+ }
147
+ /** `null` for a missing/malformed/mis-signed/wrong-field token — the caller treats that exactly like the field being absent, never like a shape error worth a different message (nothing about *why* a forged token failed should be observable). */
148
+ export function verifyFormFileToken(secret, context, token) {
149
+ const separator = token.lastIndexOf('.');
150
+ if (separator === -1)
151
+ return null;
152
+ const payload = token.slice(0, separator);
153
+ const signature = token.slice(separator + 1);
154
+ const expected = createHmac('sha256', secret)
155
+ .update(scopedPayload(context, payload))
156
+ .digest('base64url');
157
+ const provided = Buffer.from(signature, 'utf8');
158
+ const wanted = Buffer.from(expected, 'utf8');
159
+ if (provided.length !== wanted.length || !timingSafeEqual(provided, wanted))
160
+ return null;
161
+ try {
162
+ const parsed = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
163
+ return isFormFileValue(parsed) ? parsed : null;
164
+ }
165
+ catch {
166
+ return null;
167
+ }
168
+ }
169
+ //# sourceMappingURL=file-field.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"file-field.js","sourceRoot":"","sources":["../src/file-field.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AACzD,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,MAAM,eAAe,CAAA;AACjF,OAAO,EACL,oBAAoB,EAIpB,eAAe,GAChB,MAAM,YAAY,CAAA;AAEnB;;;;;;;;;;;;GAYG;AAEH,yLAAyL;AACzL,MAAM,CAAC,MAAM,wBAAwB,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAA;AACxD,MAAM,CAAC,MAAM,2BAA2B,GAAG,EAAE,GAAG,IAAI,GAAG,IAAI,CAAA;AAE3D,MAAM,aAAa,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,CAAA,CAAC,SAAS;AACxD,0EAA0E;AAC1E,uEAAuE;AACvE,2EAA2E;AAC3E,8DAA8D;AAC9D,MAAM,cAAc,GAAmC;IACrD,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;IACxB,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;IACxB,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC;CACzB,CAAA;AAED,SAAS,eAAe,CAAC,KAAiB,EAAE,SAA4B;IACtE,IAAI,KAAK,CAAC,MAAM,GAAG,SAAS,CAAC,MAAM;QAAE,OAAO,KAAK,CAAA;IACjD,OAAO,SAAS,CAAC,KAAK,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,KAAK,IAAI,CAAC,CAAA;AAChE,CAAC;AAED;;;;;;;GAOG;AACH,SAAS,kBAAkB,CAAC,KAAiB;IAC3C,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,KAAK,CAAA;IACpC,MAAM,MAAM,GAAG,KAAK,CAAC,QAAQ,CAAC,CAAC,EAAE,IAAI,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAA;IAC9D,IAAI,SAAS,GAAG,CAAC,CAAA;IACjB,KAAK,MAAM,IAAI,IAAI,MAAM,EAAE,CAAC;QAC1B,IAAI,IAAI,KAAK,IAAI;YAAE,OAAO,KAAK,CAAA;QAC/B,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;YACpD,SAAS,IAAI,CAAC,CAAA;YACd,SAAQ;QACV,CAAC;QACD,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,IAAI,EAAE,CAAC;YACjC,SAAS,IAAI,CAAC,CAAA;YACd,SAAQ;QACV,CAAC;QACD,mEAAmE;QACnE,oEAAoE;QACpE,uEAAuE;QACvE,iBAAiB;QACjB,IAAI,IAAI,IAAI,IAAI;YAAE,SAAQ;IAC5B,CAAC;IACD,OAAO,SAAS,GAAG,MAAM,CAAC,MAAM,IAAI,IAAI,CAAA;AAC1C,CAAC;AAED,wGAAwG;AACxG,MAAM,UAAU,qBAAqB,CAAC,KAAiB;IACrD,IAAI,gBAAgB,CAAC,KAAK,CAAC,KAAK,IAAI;QAAE,OAAO,OAAO,CAAA;IACpD,IAAI,eAAe,CAAC,KAAK,EAAE,aAAa,CAAC;QAAE,OAAO,KAAK,CAAA;IACvD,IAAI,cAAc,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,eAAe,CAAC,KAAK,EAAE,SAAS,CAAC,CAAC;QAAE,OAAO,UAAU,CAAA;IAC5F,IAAI,kBAAkB,CAAC,KAAK,CAAC;QAAE,OAAO,MAAM,CAAA;IAC5C,OAAO,IAAI,CAAA;AACb,CAAC;AAED,MAAM,wBAAwB,GAA+C;IAC3E,KAAK,EAAE,0BAA0B,EAAE,0JAA0J;IAC7L,GAAG,EAAE,iBAAiB;IACtB,QAAQ,EAAE,0BAA0B;IACpC,IAAI,EAAE,2BAA2B;CAClC,CAAA;AAED,2JAA2J;AAC3J,MAAM,UAAU,sBAAsB,CAAC,QAA0B;IAC/D,OAAO,wBAAwB,CAAC,QAAQ,CAAC,CAAA;AAC3C,CAAC;AAED,SAAS,QAAQ,CAAC,KAA0B,EAAE,MAAc;IAC1D,OAAO,IAAI,YAAY,CAAC;QACtB,IAAI,EAAE,oBAAoB;QAC1B,OAAO,EAAE,MAAM;QACf,IAAI,EAAE,+EAA+E;QACrF,OAAO,EAAE,EAAE,KAAK,EAAE,KAAK,CAAC,IAAI,EAAE;KAC/B,CAAC,CAAA;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,qBAAqB,CACnC,KAA0B,EAC1B,KAAiB;IAEjB,MAAM,QAAQ,GAAG,IAAI,CAAC,GAAG,CACvB,KAAK,CAAC,YAAY,IAAI,2BAA2B,EACjD,wBAAwB,CACzB,CAAA;IACD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QACpB,MAAM,QAAQ,CAAC,KAAK,EAAE,IAAI,KAAK,CAAC,IAAI,wCAAwC,CAAC,CAAA;IAC/E,IAAI,KAAK,CAAC,MAAM,GAAG,QAAQ,EAAE,CAAC;QAC5B,MAAM,QAAQ,CACZ,KAAK,EACL,IAAI,KAAK,CAAC,IAAI,wBAAwB,IAAI,CAAC,KAAK,CAAC,QAAQ,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC,CAAC,uBAAuB,CAClG,CAAA;IACH,CAAC;IAED,MAAM,QAAQ,GAAG,qBAAqB,CAAC,KAAK,CAAC,CAAA;IAC7C,MAAM,QAAQ,GAAG,KAAK,CAAC,gBAAgB,IAAI,oBAAoB,CAAA;IAC/D,IAAI,QAAQ,KAAK,IAAI,EAAE,CAAC;QACtB,MAAM,QAAQ,CACZ,KAAK,EACL,IAAI,KAAK,CAAC,IAAI,iBAAiB,iBAAiB,CAAC,KAAK,CAAC,mDAAmD,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAClI,CAAA;IACH,CAAC;IACD,IAAI,CAAC,QAAQ,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,CAAC;QACjC,MAAM,QAAQ,CACZ,KAAK,EACL,IAAI,KAAK,CAAC,IAAI,mBAAmB,QAAQ,sDAAsD,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CACvH,CAAA;IACH,CAAC;IACD,OAAO,QAAQ,CAAA;AACjB,CAAC;AAmBD,SAAS,aAAa,CAAC,OAA6B,EAAE,OAAe;IACnE,OAAO,GAAG,OAAO,CAAC,MAAM,IAAI,OAAO,CAAC,SAAS,IAAI,OAAO,EAAE,CAAA;AAC5D,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,iBAAiB,CAC/B,MAAc,EACd,OAA6B,EAC7B,KAAoB;IAEpB,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAA;IAChF,MAAM,SAAS,GAAG,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC;SAC3C,MAAM,CAAC,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;SACvC,MAAM,CAAC,WAAW,CAAC,CAAA;IACtB,OAAO,GAAG,OAAO,IAAI,SAAS,EAAE,CAAA;AAClC,CAAC;AAED,qPAAqP;AACrP,MAAM,UAAU,mBAAmB,CACjC,MAAc,EACd,OAA6B,EAC7B,KAAa;IAEb,MAAM,SAAS,GAAG,KAAK,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;IACxC,IAAI,SAAS,KAAK,CAAC,CAAC;QAAE,OAAO,IAAI,CAAA;IACjC,MAAM,OAAO,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,SAAS,CAAC,CAAA;IACzC,MAAM,SAAS,GAAG,KAAK,CAAC,KAAK,CAAC,SAAS,GAAG,CAAC,CAAC,CAAA;IAE5C,MAAM,QAAQ,GAAG,UAAU,CAAC,QAAQ,EAAE,MAAM,CAAC;SAC1C,MAAM,CAAC,aAAa,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;SACvC,MAAM,CAAC,WAAW,CAAC,CAAA;IACtB,MAAM,QAAQ,GAAG,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,CAAA;IAC/C,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAA;IAC5C,IAAI,QAAQ,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM,IAAI,CAAC,eAAe,CAAC,QAAQ,EAAE,MAAM,CAAC;QAAE,OAAO,IAAI,CAAA;IAExF,IAAI,CAAC;QACH,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,IAAI,CAAC,OAAO,EAAE,WAAW,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAA;QACtF,OAAO,eAAe,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAA;IAChD,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC"}
@@ -0,0 +1,18 @@
1
+ export { checkFillDelay, checkHoneypot, checkSubmitRateLimit, HONEYPOT_FIELD, TIMESTAMP_FIELD, } from './anti-abuse.js';
2
+ export type { CaptchaFetch, VerifyCaptchaOptions } from './captcha.js';
3
+ export { verifyCaptcha } from './captcha.js';
4
+ export { evaluateCondition, isFieldVisible } from './conditions.js';
5
+ export { CSV_BOM, csvField, csvHeaderRow, csvSubmissionRow, csvValueColumns, toCsvRow, } from './csv.js';
6
+ export type { FormFileTokenContext } from './file-field.js';
7
+ export { assertAllowedFormFile, contentTypeForCategory, DEFAULT_FORM_FILE_MAX_BYTES, FORM_FILE_HARD_MAX_BYTES, signFormFileToken, sniffFormFileCategory, verifyFormFileToken, } from './file-field.js';
8
+ export { hashIp } from './ip.js';
9
+ export type { NotifyChannelsOptions, NotifyNewSubmissionOptions, SendAutoresponderOptions, } from './notify.js';
10
+ export { buildSubmissionAlert, notifyChannels, notifyNewSubmission, sendAutoresponder, } from './notify.js';
11
+ export type { FormDefinitionStore, FormStore, FormSubmissionStore, ListSubmissionsOptions, ListSubmissionsResult, PurgeReport, SubmitOptions, } from './store.js';
12
+ export { createFormStore } from './store.js';
13
+ export { ensureFormsTables, TABLES as FORMS_TABLES } from './tables.js';
14
+ export type { AutoresponderConfig, CreateFormDefinitionInput, FormCaptchaConfig, FormConditionOperator, FormDefinition, FormFieldCondition, FormFieldDefinition, FormFieldKind, FormFileCategory, FormFileValue, FormNotifyChannel, FormStepDefinition, FormSubmission, FormSubmissionNote, FormSubmissionStatus, RecordedConsent, UpdateFormDefinitionInput, } from './types.js';
15
+ export { emailValueOf, FORM_CONDITION_OPERATORS, FORM_FIELD_KINDS, FORM_FILE_CATEGORIES, isFormFileValue, } from './types.js';
16
+ export type { ValidatedSubmission } from './validate.js';
17
+ export { validateCaptchaConfig, validateDefinitionFields, validateFormSteps, validateNotifyChannels, validateSubmission, } from './validate.js';
18
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,aAAa,EACb,oBAAoB,EACpB,cAAc,EACd,eAAe,GAChB,MAAM,iBAAiB,CAAA;AACxB,YAAY,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,cAAc,CAAA;AACtE,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAC5C,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACnE,OAAO,EACL,OAAO,EACP,QAAQ,EACR,YAAY,EACZ,gBAAgB,EAChB,eAAe,EACf,QAAQ,GACT,MAAM,UAAU,CAAA;AACjB,YAAY,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AAC3D,OAAO,EACL,qBAAqB,EACrB,sBAAsB,EACtB,2BAA2B,EAC3B,wBAAwB,EACxB,iBAAiB,EACjB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAChC,YAAY,EACV,qBAAqB,EACrB,0BAA0B,EAC1B,wBAAwB,GACzB,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,mBAAmB,EACnB,iBAAiB,GAClB,MAAM,aAAa,CAAA;AACpB,YAAY,EACV,mBAAmB,EACnB,SAAS,EACT,mBAAmB,EACnB,sBAAsB,EACtB,qBAAqB,EACrB,WAAW,EACX,aAAa,GACd,MAAM,YAAY,CAAA;AACnB,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,IAAI,YAAY,EAAE,MAAM,aAAa,CAAA;AACvE,YAAY,EACV,mBAAmB,EACnB,yBAAyB,EACzB,iBAAiB,EACjB,qBAAqB,EACrB,cAAc,EACd,kBAAkB,EAClB,mBAAmB,EACnB,aAAa,EACb,gBAAgB,EAChB,aAAa,EACb,iBAAiB,EACjB,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,oBAAoB,EACpB,eAAe,EACf,yBAAyB,GAC1B,MAAM,YAAY,CAAA;AACnB,OAAO,EACL,YAAY,EACZ,wBAAwB,EACxB,gBAAgB,EAChB,oBAAoB,EACpB,eAAe,GAChB,MAAM,YAAY,CAAA;AACnB,YAAY,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAA;AACxD,OAAO,EACL,qBAAqB,EACrB,wBAAwB,EACxB,iBAAiB,EACjB,sBAAsB,EACtB,kBAAkB,GACnB,MAAM,eAAe,CAAA"}
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export { checkFillDelay, checkHoneypot, checkSubmitRateLimit, HONEYPOT_FIELD, TIMESTAMP_FIELD, } from './anti-abuse.js';
2
+ export { verifyCaptcha } from './captcha.js';
3
+ export { evaluateCondition, isFieldVisible } from './conditions.js';
4
+ export { CSV_BOM, csvField, csvHeaderRow, csvSubmissionRow, csvValueColumns, toCsvRow, } from './csv.js';
5
+ export { assertAllowedFormFile, contentTypeForCategory, DEFAULT_FORM_FILE_MAX_BYTES, FORM_FILE_HARD_MAX_BYTES, signFormFileToken, sniffFormFileCategory, verifyFormFileToken, } from './file-field.js';
6
+ export { hashIp } from './ip.js';
7
+ export { buildSubmissionAlert, notifyChannels, notifyNewSubmission, sendAutoresponder, } from './notify.js';
8
+ export { createFormStore } from './store.js';
9
+ export { ensureFormsTables, TABLES as FORMS_TABLES } from './tables.js';
10
+ export { emailValueOf, FORM_CONDITION_OPERATORS, FORM_FIELD_KINDS, FORM_FILE_CATEGORIES, isFormFileValue, } from './types.js';
11
+ export { validateCaptchaConfig, validateDefinitionFields, validateFormSteps, validateNotifyChannels, validateSubmission, } from './validate.js';
12
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,aAAa,EACb,oBAAoB,EACpB,cAAc,EACd,eAAe,GAChB,MAAM,iBAAiB,CAAA;AAExB,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAC5C,OAAO,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AACnE,OAAO,EACL,OAAO,EACP,QAAQ,EACR,YAAY,EACZ,gBAAgB,EAChB,eAAe,EACf,QAAQ,GACT,MAAM,UAAU,CAAA;AAEjB,OAAO,EACL,qBAAqB,EACrB,sBAAsB,EACtB,2BAA2B,EAC3B,wBAAwB,EACxB,iBAAiB,EACjB,qBAAqB,EACrB,mBAAmB,GACpB,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAA;AAMhC,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,mBAAmB,EACnB,iBAAiB,GAClB,MAAM,aAAa,CAAA;AAUpB,OAAO,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,IAAI,YAAY,EAAE,MAAM,aAAa,CAAA;AAoBvE,OAAO,EACL,YAAY,EACZ,wBAAwB,EACxB,gBAAgB,EAChB,oBAAoB,EACpB,eAAe,GAChB,MAAM,YAAY,CAAA;AAEnB,OAAO,EACL,qBAAqB,EACrB,wBAAwB,EACxB,iBAAiB,EACjB,sBAAsB,EACtB,kBAAkB,GACnB,MAAM,eAAe,CAAA"}
package/dist/ip.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `sha256(ip)`, hex-encoded — never the address itself (fiche 16 acceptance
3
+ * criterion: "Aucune adresse IP en clair"). Unlike `@cogenta/analytics`'s
4
+ * daily-salted hash (built for cross-day-unlinkable visitor counting), a
5
+ * submission's IP hash has a different job: letting an operator spot "the
6
+ * same source hammering this form" *within* the anti-abuse window, and the
7
+ * rate limiter's own key needs a stable value across the window to work at
8
+ * all. A per-day salt would defeat that. It is still one-way, and still never
9
+ * the address in the clear.
10
+ */
11
+ export declare function hashIp(ip: string): string;
12
+ //# sourceMappingURL=ip.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ip.d.ts","sourceRoot":"","sources":["../src/ip.ts"],"names":[],"mappings":"AAEA;;;;;;;;;GASG;AACH,wBAAgB,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAEzC"}
package/dist/ip.js ADDED
@@ -0,0 +1,15 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * `sha256(ip)`, hex-encoded — never the address itself (fiche 16 acceptance
4
+ * criterion: "Aucune adresse IP en clair"). Unlike `@cogenta/analytics`'s
5
+ * daily-salted hash (built for cross-day-unlinkable visitor counting), a
6
+ * submission's IP hash has a different job: letting an operator spot "the
7
+ * same source hammering this form" *within* the anti-abuse window, and the
8
+ * rate limiter's own key needs a stable value across the window to work at
9
+ * all. A per-day salt would defeat that. It is still one-way, and still never
10
+ * the address in the clear.
11
+ */
12
+ export function hashIp(ip) {
13
+ return createHash('sha256').update(ip, 'utf8').digest('hex');
14
+ }
15
+ //# sourceMappingURL=ip.js.map
package/dist/ip.js.map ADDED
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ip.js","sourceRoot":"","sources":["../src/ip.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAA;AAExC;;;;;;;;;GASG;AACH,MAAM,UAAU,MAAM,CAAC,EAAU;IAC/B,OAAO,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAA;AAC9D,CAAC"}
@@ -0,0 +1,52 @@
1
+ import { type AlertChannelMessage, type ChannelRegistry, type EmailTransport } from '@cogenta/channels';
2
+ import { type RateLimitDriver } from '@cogenta/core';
3
+ import type { FormDefinition, FormNotifyChannel, FormSubmission } from './types.js';
4
+ export declare function buildSubmissionAlert(definition: FormDefinition, submission: FormSubmission, adminUrl: string): AlertChannelMessage;
5
+ export interface NotifyNewSubmissionOptions {
6
+ readonly transport: EmailTransport;
7
+ readonly definition: FormDefinition;
8
+ readonly submission: FormSubmission;
9
+ readonly adminUrl: string;
10
+ }
11
+ /** Sends the "new submission" e-mail to every configured recipient. Never throws on a single bad address — logs are the caller's job, this returns what happened. */
12
+ export declare function notifyNewSubmission(options: NotifyNewSubmissionOptions): Promise<{
13
+ readonly sent: readonly string[];
14
+ readonly failed: readonly string[];
15
+ }>;
16
+ export interface NotifyChannelsOptions {
17
+ readonly registry: ChannelRegistry;
18
+ readonly definition: FormDefinition;
19
+ readonly submission: FormSubmission;
20
+ readonly adminUrl: string;
21
+ }
22
+ /**
23
+ * Fiche 47 task 4 — the multi-channel half of a submission notification,
24
+ * built the same way `notifyNewSubmission` builds the e-mail one: reusing
25
+ * `buildSubmissionAlert` (never a second message shape) and the adapters
26
+ * `@cogenta/channels` already has, never a new transport of this package's
27
+ * own (R1/R9). A channel this form names but the site never configured (no
28
+ * live adapter of that name in the registry), or a channel whose `send`
29
+ * itself fails, is recorded as `failed` and every other configured channel
30
+ * still gets tried — the same "never throws on one bad recipient" contract
31
+ * `notifyNewSubmission` already holds for e-mail addresses.
32
+ */
33
+ export declare function notifyChannels(options: NotifyChannelsOptions): Promise<{
34
+ readonly sent: readonly FormNotifyChannel[];
35
+ readonly failed: readonly FormNotifyChannel[];
36
+ }>;
37
+ /**
38
+ * The autoresponder — disabled by default, and rate-limited **per recipient
39
+ * address** on top of the generic per-IP submission limiter, because the
40
+ * address here was supplied by an anonymous visitor: without a hard cap this
41
+ * is a script that makes the site's domain send mail to any inbox it likes,
42
+ * on demand (fiche 16 § pièges: "l'accusé de réception est un relais de
43
+ * spam potentiel").
44
+ */
45
+ export interface SendAutoresponderOptions {
46
+ readonly transport: EmailTransport;
47
+ readonly definition: FormDefinition;
48
+ readonly recipientEmail: string;
49
+ readonly rateLimit: RateLimitDriver;
50
+ }
51
+ export declare function sendAutoresponder(options: SendAutoresponderOptions): Promise<void>;
52
+ //# sourceMappingURL=notify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../src/notify.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,mBAAmB,EACxB,KAAK,eAAe,EAEpB,KAAK,cAAc,EACpB,MAAM,mBAAmB,CAAA;AAC1B,OAAO,EAAgB,KAAK,eAAe,EAAE,MAAM,eAAe,CAAA;AAClE,OAAO,KAAK,EAAE,cAAc,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAA;AAyBnF,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,cAAc,EAC1B,UAAU,EAAE,cAAc,EAC1B,QAAQ,EAAE,MAAM,GACf,mBAAmB,CAarB;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAA;IAClC,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAA;IACnC,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAA;IACnC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED,qKAAqK;AACrK,wBAAsB,mBAAmB,CACvC,OAAO,EAAE,0BAA0B,GAClC,OAAO,CAAC;IAAE,QAAQ,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,CAAC;IAAC,QAAQ,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;CAAE,CAAC,CAiBnF;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,QAAQ,EAAE,eAAe,CAAA;IAClC,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAA;IACnC,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAA;IACnC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,cAAc,CAAC,OAAO,EAAE,qBAAqB,GAAG,OAAO,CAAC;IAC5E,QAAQ,CAAC,IAAI,EAAE,SAAS,iBAAiB,EAAE,CAAA;IAC3C,QAAQ,CAAC,MAAM,EAAE,SAAS,iBAAiB,EAAE,CAAA;CAC9C,CAAC,CAoBD;AAKD;;;;;;;GAOG;AACH,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,SAAS,EAAE,cAAc,CAAA;IAClC,QAAQ,CAAC,UAAU,EAAE,cAAc,CAAA;IACnC,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAA;IAC/B,QAAQ,CAAC,SAAS,EAAE,eAAe,CAAA;CACpC;AAED,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAyBxF"}
package/dist/notify.js ADDED
@@ -0,0 +1,113 @@
1
+ import { createEmailAdapter, } from '@cogenta/channels';
2
+ import { CogentaError } from '@cogenta/core';
3
+ /**
4
+ * Notifications for a new submission — "réutiliser l'adaptateur e-mail
5
+ * existant de `@cogenta/channels`, jamais un second transport" (ADR-0026).
6
+ *
7
+ * The message goes through the same `AlertChannelMessage` shape and
8
+ * `createEmailAdapter` every other alert in this codebase already renders
9
+ * through (fiche 16 task 5: "réutiliser les formats de message de
10
+ * `@cogenta/channels`... plutôt qu'en inventant un message") — this file
11
+ * never writes its own subject/body HTML, it only describes *what happened*
12
+ * and lets the adapter decide how that becomes an e-mail.
13
+ */
14
+ function fieldSummary(definition, submission) {
15
+ const lines = definition.fields
16
+ .filter((field) => field.kind !== 'consent')
17
+ .map((field) => {
18
+ const value = submission.values[field.name];
19
+ const text = Array.isArray(value) ? value.join(', ') : (value ?? '');
20
+ return `${field.label}: ${text === '' ? '(empty)' : text}`;
21
+ });
22
+ return lines.join('\n');
23
+ }
24
+ export function buildSubmissionAlert(definition, submission, adminUrl) {
25
+ return {
26
+ level: 'alert',
27
+ title: `New submission — ${definition.label}`,
28
+ severity: 'info',
29
+ // The submission's own field values, verbatim — this is external content
30
+ // (R8), but the destination here is a rendered e-mail read by a human,
31
+ // never an agent's prompt, so a plain, clearly-labelled dump is the
32
+ // right shape rather than a structured `data` channel meant for a model.
33
+ context: fieldSummary(definition, submission),
34
+ expectedAction: 'Review the submission in the admin.',
35
+ adminUrl,
36
+ };
37
+ }
38
+ /** Sends the "new submission" e-mail to every configured recipient. Never throws on a single bad address — logs are the caller's job, this returns what happened. */
39
+ export async function notifyNewSubmission(options) {
40
+ if (options.definition.notifyEmails.length === 0)
41
+ return { sent: [], failed: [] };
42
+ const adapter = createEmailAdapter({ transport: options.transport });
43
+ const message = buildSubmissionAlert(options.definition, options.submission, options.adminUrl);
44
+ const sent = [];
45
+ const failed = [];
46
+ for (const email of options.definition.notifyEmails) {
47
+ try {
48
+ await adapter.send({ id: email }, message);
49
+ sent.push(email);
50
+ }
51
+ catch {
52
+ failed.push(email);
53
+ }
54
+ }
55
+ return { sent, failed };
56
+ }
57
+ /**
58
+ * Fiche 47 task 4 — the multi-channel half of a submission notification,
59
+ * built the same way `notifyNewSubmission` builds the e-mail one: reusing
60
+ * `buildSubmissionAlert` (never a second message shape) and the adapters
61
+ * `@cogenta/channels` already has, never a new transport of this package's
62
+ * own (R1/R9). A channel this form names but the site never configured (no
63
+ * live adapter of that name in the registry), or a channel whose `send`
64
+ * itself fails, is recorded as `failed` and every other configured channel
65
+ * still gets tried — the same "never throws on one bad recipient" contract
66
+ * `notifyNewSubmission` already holds for e-mail addresses.
67
+ */
68
+ export async function notifyChannels(options) {
69
+ if (options.definition.notifyChannels.length === 0)
70
+ return { sent: [], failed: [] };
71
+ const message = buildSubmissionAlert(options.definition, options.submission, options.adminUrl);
72
+ const sent = [];
73
+ const failed = [];
74
+ for (const entry of options.definition.notifyChannels) {
75
+ if (!options.registry.has(entry.channel)) {
76
+ failed.push(entry);
77
+ continue;
78
+ }
79
+ try {
80
+ await options.registry.get(entry.channel).send({ id: entry.target }, message);
81
+ sent.push(entry);
82
+ }
83
+ catch {
84
+ failed.push(entry);
85
+ }
86
+ }
87
+ return { sent, failed };
88
+ }
89
+ const AUTORESPONDER_WINDOW_MS = 60 * 60 * 1000;
90
+ const AUTORESPONDER_LIMIT_PER_WINDOW = 1;
91
+ export async function sendAutoresponder(options) {
92
+ const { definition } = options;
93
+ if (!definition.autoresponder.enabled)
94
+ return;
95
+ const key = `forms:autoresponder:${definition.id}:${options.recipientEmail.toLowerCase()}`;
96
+ const result = await options.rateLimit.consume(key, {
97
+ limit: AUTORESPONDER_LIMIT_PER_WINDOW,
98
+ windowMs: AUTORESPONDER_WINDOW_MS,
99
+ });
100
+ if (!result.allowed) {
101
+ throw new CogentaError({
102
+ code: 'FORM_AUTORESPONDER_RATE_LIMITED',
103
+ message: `An autoresponder was already sent to this address for "${definition.name}" recently.`,
104
+ hint: 'This is a deliberate cap — it exists to stop this form from being used to relay mail to an arbitrary inbox.',
105
+ });
106
+ }
107
+ const adapter = createEmailAdapter({ transport: options.transport });
108
+ await adapter.send({ id: options.recipientEmail }, {
109
+ level: 'notification',
110
+ text: options.definition.autoresponder.body ?? 'Thank you — we received your message.',
111
+ });
112
+ }
113
+ //# sourceMappingURL=notify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify.js","sourceRoot":"","sources":["../src/notify.ts"],"names":[],"mappings":"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"}
package/dist/rows.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Reading rows back out of three databases that disagree about them — the
3
+ * same trouble `@cogenta/commerce`'s `rows.ts` documents, restated here
4
+ * because this package owns its own tables and does not depend on commerce.
5
+ */
6
+ export declare function toText(value: unknown, what: string): string;
7
+ export declare function toNullableText(value: unknown): string | null;
8
+ export declare function toInt(value: unknown, what: string): number;
9
+ export declare function toBool(value: unknown): boolean;
10
+ /** Postgres wants a real boolean; MySQL/SQLite want 0/1. */
11
+ export declare function fromBool(value: boolean, dialect: string): boolean | number;
12
+ export declare function toJson<T>(value: unknown, what: string): T;
13
+ //# sourceMappingURL=rows.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rows.d.ts","sourceRoot":"","sources":["../src/rows.ts"],"names":[],"mappings":"AAEA;;;;GAIG;AAEH,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAS3D;AAED,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAI5D;AAED,wBAAgB,KAAK,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,MAAM,CAa1D;AAED,wBAAgB,MAAM,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAM9C;AAED,4DAA4D;AAC5D,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,GAAG,MAAM,CAE1E;AAED,wBAAgB,MAAM,CAAC,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,CAYzD"}