@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.
- package/LICENSE +373 -0
- package/dist/anti-abuse.d.ts +16 -0
- package/dist/anti-abuse.d.ts.map +1 -0
- package/dist/anti-abuse.js +58 -0
- package/dist/anti-abuse.js.map +1 -0
- package/dist/captcha.d.ts +18 -0
- package/dist/captcha.d.ts.map +1 -0
- package/dist/captcha.js +69 -0
- package/dist/captcha.js.map +1 -0
- package/dist/conditions.d.ts +6 -0
- package/dist/conditions.d.ts.map +1 -0
- package/dist/conditions.js +52 -0
- package/dist/conditions.js.map +1 -0
- package/dist/csv.d.ts +10 -0
- package/dist/csv.d.ts.map +1 -0
- package/dist/csv.js +56 -0
- package/dist/csv.js.map +1 -0
- package/dist/file-field.d.ts +64 -0
- package/dist/file-field.d.ts.map +1 -0
- package/dist/file-field.js +169 -0
- package/dist/file-field.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/ip.d.ts +12 -0
- package/dist/ip.d.ts.map +1 -0
- package/dist/ip.js +15 -0
- package/dist/ip.js.map +1 -0
- package/dist/notify.d.ts +52 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.js +113 -0
- package/dist/notify.js.map +1 -0
- package/dist/rows.d.ts +13 -0
- package/dist/rows.d.ts.map +1 -0
- package/dist/rows.js +72 -0
- package/dist/rows.js.map +1 -0
- package/dist/store.d.ts +71 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +509 -0
- package/dist/store.js.map +1 -0
- package/dist/tables.d.ts +21 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +131 -0
- package/dist/tables.js.map +1 -0
- package/dist/types.d.ts +181 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +56 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +20 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +353 -0
- package/dist/validate.js.map +1 -0
- 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
|
package/dist/csv.js.map
ADDED
|
@@ -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":"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"}
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
package/dist/ip.d.ts.map
ADDED
|
@@ -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"}
|
package/dist/notify.d.ts
ADDED
|
@@ -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"}
|