@blamejs/blamejs-shop 0.0.60 → 0.0.62

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.
@@ -0,0 +1,884 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.storefrontForms
4
+ * @title Storefront forms — operator-defined contact / lead-capture
5
+ *
6
+ * @intro
7
+ * Operator-defined contact / lead-capture / "request a quote" /
8
+ * "wholesale application" forms. The operator declares the field
9
+ * shape + a submit destination; customers submit values; the
10
+ * primitive validates each field against its declared kind,
11
+ * refuses missing required fields, and dispatches the captured
12
+ * values through the injected `notifications` (email) or
13
+ * `webhooks` (webhook) dep.
14
+ *
15
+ * Each form carries:
16
+ * - a stable URL-friendly `slug` (the PK + the storefront route
17
+ * fragment under `/forms/<slug>`),
18
+ * - a `title` shown in the page header,
19
+ * - an optional `description` (one paragraph of operator copy),
20
+ * - a `fields` array — each `{ name, kind, required, label,
21
+ * options? }` — drawing `kind` from a closed enum
22
+ * (`text / email / phone / textarea / select / checkbox /
23
+ * number`),
24
+ * - a `submit_to` target `{ kind, value }` — `kind` is `email`
25
+ * or `webhook`, `value` is the recipient address or the
26
+ * webhook event name,
27
+ * - a `success_message` rendered after a successful submit,
28
+ * - an optional `throttle_per_minute_per_session` rate limit
29
+ * (defaults to 5; 0 disables the throttle).
30
+ *
31
+ * Field-validation surface (per-kind):
32
+ * text — non-empty string ≤ 1024 chars, no control bytes
33
+ * email — `b.guardEmail.validate(..., profile: "strict")`
34
+ * phone — digits / spaces / dashes / `+` / `(` / `)`,
35
+ * ≤ 32 chars
36
+ * textarea — non-empty string ≤ 8192 chars, LF allowed,
37
+ * no NUL / DEL
38
+ * select — string that appears verbatim in the field's
39
+ * declared `options` list
40
+ * checkbox — strict boolean (`true` / `false`)
41
+ * number — finite number (integer or decimal); rejects
42
+ * NaN / Infinity / non-numeric strings
43
+ *
44
+ * Throttle behavior — `throttleCheck(...)` counts rows in
45
+ * `storefront_form_submissions` over the last 60s for the same
46
+ * `(form_slug, session_id_hash)` tuple. `submit(...)` calls
47
+ * `throttleCheck(...)` before persisting; over-limit submits are
48
+ * refused with `{ ok: false, error: "throttled" }` and no row is
49
+ * written.
50
+ *
51
+ * Session-id privacy — the raw session id never lands in the
52
+ * table. The primitive hashes it through
53
+ * `b.crypto.namespaceHash("storefront-form-session", ...)` before
54
+ * it reaches `session_id_hash`, so a DB compromise can't unmask
55
+ * visitor session ids. Callers that already pre-hashed the
56
+ * session id can pass `session_id_hash` directly instead of
57
+ * `session_id` (the throttle window still works either way).
58
+ *
59
+ * Dispatch — `submit(...)` validates first, persists the
60
+ * submission row, THEN dispatches. A dispatch failure is recorded
61
+ * in `dispatch_error` on the submission row (drop-silent — the
62
+ * submission still landed; the operator reviews failed-dispatch
63
+ * rows offline). If no dispatch dep is injected, the row is
64
+ * written with `dispatched_at = NULL` and the call returns
65
+ * `{ ok: true, id, dispatched: false }`.
66
+ *
67
+ * Composes:
68
+ * - `b.guardEmail.validate` — email field validation
69
+ * - `b.crypto.namespaceHash` — session-id hashing
70
+ * - `b.uuid.v7` — submission row ids
71
+ *
72
+ * @primitive storefrontForms
73
+ * @related notifications, webhooks, b.guardEmail, b.crypto
74
+ */
75
+
76
+ var MAX_SLUG_LEN = 80;
77
+ var MAX_TITLE_LEN = 200;
78
+ var MAX_DESCRIPTION_LEN = 2000;
79
+ var MAX_FIELD_NAME_LEN = 64;
80
+ var MAX_FIELD_LABEL_LEN = 200;
81
+ var MAX_FIELDS = 32;
82
+ var MAX_OPTIONS_PER_FIELD = 64;
83
+ var MAX_OPTION_LEN = 200;
84
+ var MAX_SUBMIT_TO_VALUE_LEN = 320;
85
+ var MAX_SUCCESS_MESSAGE_LEN = 1000;
86
+ var MAX_TEXT_VALUE_LEN = 1024;
87
+ var MAX_TEXTAREA_VALUE_LEN = 8192;
88
+ var MAX_PHONE_VALUE_LEN = 32;
89
+ var MAX_LIST_LIMIT = 200;
90
+ var DEFAULT_LIST_LIMIT = 50;
91
+ var DEFAULT_THROTTLE_LIMIT = 5;
92
+ var MAX_THROTTLE_LIMIT = 1000;
93
+ var THROTTLE_WINDOW_MS = 60 * 1000;
94
+
95
+ var SESSION_NAMESPACE = "storefront-form-session";
96
+
97
+ var FIELD_KINDS = Object.freeze([
98
+ "text",
99
+ "email",
100
+ "phone",
101
+ "textarea",
102
+ "select",
103
+ "checkbox",
104
+ "number",
105
+ ]);
106
+
107
+ var SUBMIT_TO_KINDS = Object.freeze([
108
+ "email",
109
+ "webhook",
110
+ ]);
111
+
112
+ var ALLOWED_UPDATE_COLUMNS = Object.freeze([
113
+ "title",
114
+ "description",
115
+ "fields",
116
+ "submit_to",
117
+ "success_message",
118
+ "throttle_per_minute_per_session",
119
+ ]);
120
+
121
+ // Slug shape mirrors the rest of the storefront primitives — alnum
122
+ // leading char, alnum + dot + hyphen + underscore tail.
123
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
124
+
125
+ // Field name shape — operator-authored identifier reused as the key
126
+ // in the submitted `values` object. Tight ASCII shape so a
127
+ // surprising character can't smuggle a JSON-pointer / SQL-fragment
128
+ // shape through the submission row.
129
+ var FIELD_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
130
+
131
+ // Phone validator — digits / spaces / dashes / plus / parens. Loose
132
+ // on purpose; the operator's downstream CRM normalizes to E.164,
133
+ // the primitive just refuses obvious garbage.
134
+ var PHONE_VALUE_RE = /^[0-9+()\-\s]{1,32}$/;
135
+
136
+ // Webhook event name shape — `domain.action[.qualifier]` segments.
137
+ // Mirrors the webhook-subscriptions event-name shape so an operator
138
+ // can route a form submission through the same dispatch pipeline as
139
+ // any other event.
140
+ var WEBHOOK_EVENT_RE = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*$/;
141
+
142
+ // Refuse C0 control bytes + DEL in single-line strings. Textarea
143
+ // values permit LF (the customer's free-text response is line-
144
+ // oriented).
145
+ var CONTROL_BYTE_LINE_RE = /[\x00-\x1f\x7f]/;
146
+ var CONTROL_BYTE_BLOCK_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
147
+
148
+ // Zero-width / direction-override family. Spelled with \u-escapes
149
+ // so ESLint's no-irregular-whitespace stays happy.
150
+ var ZERO_WIDTH_RE = new RegExp(
151
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
152
+ );
153
+
154
+ var bShop;
155
+ function _b() {
156
+ if (!bShop) bShop = require("./index");
157
+ return bShop.framework;
158
+ }
159
+
160
+ // ---- validators ---------------------------------------------------------
161
+
162
+ function _slug(s) {
163
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
164
+ throw new TypeError("storefrontForms: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SLUG_LEN + " chars)");
165
+ }
166
+ return s;
167
+ }
168
+
169
+ function _line(s, label, maxLen, optional) {
170
+ if (s == null) {
171
+ if (optional) return null;
172
+ throw new TypeError("storefrontForms: " + label + " is required");
173
+ }
174
+ if (typeof s !== "string" || !s.length || s.length > maxLen) {
175
+ throw new TypeError("storefrontForms: " + label + " must be a non-empty string ≤ " + maxLen + " chars");
176
+ }
177
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
178
+ throw new TypeError("storefrontForms: " + label + " contains control bytes (incl. CR/LF)");
179
+ }
180
+ if (ZERO_WIDTH_RE.test(s)) {
181
+ throw new TypeError("storefrontForms: " + label + " contains zero-width / direction-override characters");
182
+ }
183
+ return s;
184
+ }
185
+
186
+ function _description(s) {
187
+ if (s == null) return null;
188
+ if (typeof s !== "string" || s.length > MAX_DESCRIPTION_LEN) {
189
+ throw new TypeError("storefrontForms: description must be a string ≤ " + MAX_DESCRIPTION_LEN + " chars");
190
+ }
191
+ if (CONTROL_BYTE_BLOCK_RE.test(s)) {
192
+ throw new TypeError("storefrontForms: description contains control bytes");
193
+ }
194
+ if (ZERO_WIDTH_RE.test(s)) {
195
+ throw new TypeError("storefrontForms: description contains zero-width / direction-override characters");
196
+ }
197
+ return s;
198
+ }
199
+
200
+ function _fieldName(s) {
201
+ if (typeof s !== "string" || !FIELD_NAME_RE.test(s)) {
202
+ throw new TypeError("storefrontForms: field name must match /^[a-z][a-z0-9_]*$/ (≤ " + MAX_FIELD_NAME_LEN + " chars)");
203
+ }
204
+ return s;
205
+ }
206
+
207
+ function _fieldKind(s) {
208
+ if (typeof s !== "string" || FIELD_KINDS.indexOf(s) === -1) {
209
+ throw new TypeError("storefrontForms: field kind must be one of " + JSON.stringify(FIELD_KINDS));
210
+ }
211
+ return s;
212
+ }
213
+
214
+ function _fields(input) {
215
+ if (!Array.isArray(input) || !input.length) {
216
+ throw new TypeError("storefrontForms: fields must be a non-empty array");
217
+ }
218
+ if (input.length > MAX_FIELDS) {
219
+ throw new TypeError("storefrontForms: fields must contain ≤ " + MAX_FIELDS + " entries");
220
+ }
221
+ var seenNames = {};
222
+ var out = [];
223
+ for (var i = 0; i < input.length; i += 1) {
224
+ var f = input[i];
225
+ if (!f || typeof f !== "object") {
226
+ throw new TypeError("storefrontForms: fields[" + i + "] must be an object");
227
+ }
228
+ var name = _fieldName(f.name);
229
+ if (Object.prototype.hasOwnProperty.call(seenNames, name)) {
230
+ throw new TypeError("storefrontForms: duplicate field name " + JSON.stringify(name));
231
+ }
232
+ seenNames[name] = true;
233
+ var kind = _fieldKind(f.kind);
234
+ if (typeof f.required !== "boolean") {
235
+ throw new TypeError("storefrontForms: fields[" + i + "].required must be a boolean");
236
+ }
237
+ var label = _line(f.label, "fields[" + i + "].label", MAX_FIELD_LABEL_LEN);
238
+ var options = null;
239
+ if (kind === "select") {
240
+ if (!Array.isArray(f.options) || !f.options.length) {
241
+ throw new TypeError("storefrontForms: fields[" + i + "] (select) must declare a non-empty options array");
242
+ }
243
+ if (f.options.length > MAX_OPTIONS_PER_FIELD) {
244
+ throw new TypeError("storefrontForms: fields[" + i + "] (select) options must contain ≤ " + MAX_OPTIONS_PER_FIELD + " entries");
245
+ }
246
+ var seenOpts = {};
247
+ options = [];
248
+ for (var j = 0; j < f.options.length; j += 1) {
249
+ var opt = f.options[j];
250
+ if (typeof opt !== "string" || !opt.length || opt.length > MAX_OPTION_LEN) {
251
+ throw new TypeError("storefrontForms: fields[" + i + "].options[" + j + "] must be a non-empty string ≤ " + MAX_OPTION_LEN + " chars");
252
+ }
253
+ if (CONTROL_BYTE_LINE_RE.test(opt) || ZERO_WIDTH_RE.test(opt)) {
254
+ throw new TypeError("storefrontForms: fields[" + i + "].options[" + j + "] contains control / zero-width characters");
255
+ }
256
+ if (Object.prototype.hasOwnProperty.call(seenOpts, opt)) {
257
+ throw new TypeError("storefrontForms: fields[" + i + "].options has duplicate " + JSON.stringify(opt));
258
+ }
259
+ seenOpts[opt] = true;
260
+ options.push(opt);
261
+ }
262
+ } else if (f.options != null) {
263
+ throw new TypeError("storefrontForms: fields[" + i + "].options only valid on kind=select");
264
+ }
265
+ var entry = { name: name, kind: kind, required: f.required, label: label };
266
+ if (options) entry.options = options;
267
+ out.push(entry);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ function _submitTo(input) {
273
+ if (!input || typeof input !== "object") {
274
+ throw new TypeError("storefrontForms: submit_to must be an object { kind, value }");
275
+ }
276
+ if (typeof input.kind !== "string" || SUBMIT_TO_KINDS.indexOf(input.kind) === -1) {
277
+ throw new TypeError("storefrontForms: submit_to.kind must be one of " + JSON.stringify(SUBMIT_TO_KINDS));
278
+ }
279
+ if (typeof input.value !== "string" || !input.value.length || input.value.length > MAX_SUBMIT_TO_VALUE_LEN) {
280
+ throw new TypeError("storefrontForms: submit_to.value must be a non-empty string ≤ " + MAX_SUBMIT_TO_VALUE_LEN + " chars");
281
+ }
282
+ if (CONTROL_BYTE_LINE_RE.test(input.value) || ZERO_WIDTH_RE.test(input.value)) {
283
+ throw new TypeError("storefrontForms: submit_to.value contains control / zero-width characters");
284
+ }
285
+ if (input.kind === "email") {
286
+ var v = _b().guardEmail.validate(input.value, { profile: "strict" });
287
+ if (!v.ok) {
288
+ var ruleId = v.issues && v.issues[0] && v.issues[0].ruleId || "shape";
289
+ throw new TypeError("storefrontForms: submit_to.value (email) rejected (" + ruleId + ")");
290
+ }
291
+ return { kind: "email", value: _b().guardEmail.sanitize(input.value, { profile: "strict" }) };
292
+ }
293
+ // webhook
294
+ if (!WEBHOOK_EVENT_RE.test(input.value)) {
295
+ throw new TypeError("storefrontForms: submit_to.value (webhook) must match /^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)*$/");
296
+ }
297
+ return { kind: "webhook", value: input.value };
298
+ }
299
+
300
+ function _successMessage(s) {
301
+ return _line(s, "success_message", MAX_SUCCESS_MESSAGE_LEN);
302
+ }
303
+
304
+ function _throttle(n) {
305
+ if (n == null) return DEFAULT_THROTTLE_LIMIT;
306
+ if (!Number.isInteger(n) || n < 0 || n > MAX_THROTTLE_LIMIT) {
307
+ throw new TypeError("storefrontForms: throttle_per_minute_per_session must be a non-negative integer ≤ " + MAX_THROTTLE_LIMIT);
308
+ }
309
+ return n;
310
+ }
311
+
312
+ function _listLimit(n) {
313
+ if (n == null) return DEFAULT_LIST_LIMIT;
314
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
315
+ throw new TypeError("storefrontForms: limit must be an integer 1.." + MAX_LIST_LIMIT);
316
+ }
317
+ return n;
318
+ }
319
+
320
+ function _uaClass(s) {
321
+ if (s == null) return null;
322
+ if (typeof s !== "string" || !s.length || s.length > 64) {
323
+ throw new TypeError("storefrontForms: ua_class must be a non-empty string ≤ 64 chars");
324
+ }
325
+ if (!/^[a-z0-9_-]+$/.test(s)) {
326
+ throw new TypeError("storefrontForms: ua_class must match /^[a-z0-9_-]+$/");
327
+ }
328
+ return s;
329
+ }
330
+
331
+ function _ipHash(s) {
332
+ if (s == null) return null;
333
+ if (typeof s !== "string" || !s.length || s.length > 256) {
334
+ throw new TypeError("storefrontForms: ip_hash must be a non-empty string ≤ 256 chars");
335
+ }
336
+ if (CONTROL_BYTE_LINE_RE.test(s)) {
337
+ throw new TypeError("storefrontForms: ip_hash contains control bytes");
338
+ }
339
+ return s;
340
+ }
341
+
342
+ function _now() { return Date.now(); }
343
+
344
+ // ---- per-field value validator -----------------------------------------
345
+ //
346
+ // Returns `{ ok: true, value }` on success or `{ ok: false, error }`
347
+ // on failure. The caller aggregates the per-field results into a
348
+ // single submission outcome so the customer sees every failed field
349
+ // at once, not one-at-a-time.
350
+
351
+ function _validateValue(field, raw) {
352
+ // Missing / empty handling. `undefined`, `null`, and `""` are
353
+ // all "missing"; the required gate fires when missing AND
354
+ // required. Checkbox unchecked maps to `false`, not "missing".
355
+ var isMissing = raw == null || raw === "";
356
+ if (isMissing && field.kind !== "checkbox") {
357
+ if (field.required) return { ok: false, error: "required" };
358
+ return { ok: true, value: null };
359
+ }
360
+
361
+ if (field.kind === "text") {
362
+ if (typeof raw !== "string" || raw.length > MAX_TEXT_VALUE_LEN) {
363
+ return { ok: false, error: "text value must be a string ≤ " + MAX_TEXT_VALUE_LEN + " chars" };
364
+ }
365
+ if (CONTROL_BYTE_LINE_RE.test(raw) || ZERO_WIDTH_RE.test(raw)) {
366
+ return { ok: false, error: "text value contains control / zero-width characters" };
367
+ }
368
+ return { ok: true, value: raw };
369
+ }
370
+
371
+ if (field.kind === "email") {
372
+ if (typeof raw !== "string" || !raw.length || raw.length > 320) {
373
+ return { ok: false, error: "email value must be a string 1..320 chars" };
374
+ }
375
+ var v = _b().guardEmail.validate(raw, { profile: "strict" });
376
+ if (!v.ok) {
377
+ var ruleId = v.issues && v.issues[0] && v.issues[0].ruleId || "shape";
378
+ return { ok: false, error: "email rejected (" + ruleId + ")" };
379
+ }
380
+ return { ok: true, value: _b().guardEmail.sanitize(raw, { profile: "strict" }) };
381
+ }
382
+
383
+ if (field.kind === "phone") {
384
+ if (typeof raw !== "string" || !PHONE_VALUE_RE.test(raw)) {
385
+ return { ok: false, error: "phone value must match /^[0-9+()\\-\\s]{1," + MAX_PHONE_VALUE_LEN + "}$/" };
386
+ }
387
+ return { ok: true, value: raw };
388
+ }
389
+
390
+ if (field.kind === "textarea") {
391
+ if (typeof raw !== "string" || !raw.length || raw.length > MAX_TEXTAREA_VALUE_LEN) {
392
+ return { ok: false, error: "textarea value must be a non-empty string ≤ " + MAX_TEXTAREA_VALUE_LEN + " chars" };
393
+ }
394
+ if (CONTROL_BYTE_BLOCK_RE.test(raw) || ZERO_WIDTH_RE.test(raw)) {
395
+ return { ok: false, error: "textarea value contains control / zero-width characters" };
396
+ }
397
+ return { ok: true, value: raw };
398
+ }
399
+
400
+ if (field.kind === "select") {
401
+ if (typeof raw !== "string") {
402
+ return { ok: false, error: "select value must be a string drawn from the declared options" };
403
+ }
404
+ if (!field.options || field.options.indexOf(raw) === -1) {
405
+ return { ok: false, error: "select value must be one of " + JSON.stringify(field.options || []) };
406
+ }
407
+ return { ok: true, value: raw };
408
+ }
409
+
410
+ if (field.kind === "checkbox") {
411
+ if (raw === true || raw === false) {
412
+ if (field.required && raw === false) return { ok: false, error: "required" };
413
+ return { ok: true, value: raw };
414
+ }
415
+ return { ok: false, error: "checkbox value must be a strict boolean" };
416
+ }
417
+
418
+ if (field.kind === "number") {
419
+ var n;
420
+ if (typeof raw === "number") {
421
+ n = raw;
422
+ } else if (typeof raw === "string" && raw.length && raw.length <= 64) {
423
+ // Strict numeric-string shape so something like "1e9999" or
424
+ // " 3" or "" or "0x1f" doesn't slip through. Accept a
425
+ // signed integer or decimal.
426
+ if (!/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?$/.test(raw)) {
427
+ return { ok: false, error: "number value must be a finite numeric string" };
428
+ }
429
+ n = Number(raw);
430
+ } else {
431
+ return { ok: false, error: "number value must be a finite number or numeric string" };
432
+ }
433
+ if (!Number.isFinite(n)) {
434
+ return { ok: false, error: "number value must be finite (no NaN / Infinity)" };
435
+ }
436
+ return { ok: true, value: n };
437
+ }
438
+
439
+ // Unreachable — _fields() refuses unknown kinds at define time.
440
+ return { ok: false, error: "unsupported field kind " + JSON.stringify(field.kind) };
441
+ }
442
+
443
+ // ---- row hydration ------------------------------------------------------
444
+
445
+ function _hydrateFormRow(r) {
446
+ if (!r) return null;
447
+ var fields, submitTo;
448
+ try { fields = JSON.parse(r.fields_json); }
449
+ catch (_e) { fields = []; }
450
+ try { submitTo = JSON.parse(r.submit_to_json); }
451
+ catch (_e2) { submitTo = null; }
452
+ return {
453
+ slug: r.slug,
454
+ title: r.title,
455
+ description: r.description,
456
+ fields: fields,
457
+ submit_to: submitTo,
458
+ success_message: r.success_message,
459
+ throttle_per_minute_per_session: Number(r.throttle_per_minute_per_session),
460
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
461
+ created_at: Number(r.created_at),
462
+ updated_at: Number(r.updated_at),
463
+ };
464
+ }
465
+
466
+ function _hydrateSubmissionRow(r) {
467
+ if (!r) return null;
468
+ var values;
469
+ try { values = JSON.parse(r.values_json); }
470
+ catch (_e) { values = {}; }
471
+ return {
472
+ id: r.id,
473
+ form_slug: r.form_slug,
474
+ values: values,
475
+ session_id_hash: r.session_id_hash == null ? null : r.session_id_hash,
476
+ ip_hash: r.ip_hash == null ? null : r.ip_hash,
477
+ ua_class: r.ua_class == null ? null : r.ua_class,
478
+ dispatched_at: r.dispatched_at == null ? null : Number(r.dispatched_at),
479
+ dispatch_error: r.dispatch_error == null ? null : r.dispatch_error,
480
+ created_at: Number(r.created_at),
481
+ };
482
+ }
483
+
484
+ // ---- factory ------------------------------------------------------------
485
+
486
+ function create(opts) {
487
+ opts = opts || {};
488
+ var query = opts.query;
489
+ if (!query) {
490
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
491
+ }
492
+ // Optional dispatch deps. Both are duck-typed (we only check for
493
+ // the call we make) so a test collector can stand in for the real
494
+ // primitive without implementing the whole surface.
495
+ var notifications = opts.notifications || null;
496
+ var webhooks = opts.webhooks || null;
497
+
498
+ function _hashSession(sessionId) {
499
+ return _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
500
+ }
501
+
502
+ // -- defineForm -------------------------------------------------------
503
+
504
+ async function defineForm(input) {
505
+ if (!input || typeof input !== "object") {
506
+ throw new TypeError("storefrontForms.defineForm: input object required");
507
+ }
508
+ var slug = _slug(input.slug);
509
+ var title = _line(input.title, "title", MAX_TITLE_LEN);
510
+ var description = _description(input.description);
511
+ var fields = _fields(input.fields);
512
+ var submitTo = _submitTo(input.submit_to);
513
+ var successMessage = _successMessage(input.success_message);
514
+ var throttle = _throttle(input.throttle_per_minute_per_session);
515
+
516
+ var ts = _now();
517
+ await query(
518
+ "INSERT INTO storefront_forms (slug, title, description, fields_json, submit_to_json, " +
519
+ "success_message, throttle_per_minute_per_session, archived_at, created_at, updated_at) " +
520
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)",
521
+ [slug, title, description, JSON.stringify(fields), JSON.stringify(submitTo), successMessage, throttle, ts],
522
+ );
523
+ return await getForm(slug);
524
+ }
525
+
526
+ // -- getForm / listForms ---------------------------------------------
527
+
528
+ async function getForm(slug) {
529
+ _slug(slug);
530
+ var r = (await query(
531
+ "SELECT * FROM storefront_forms WHERE slug = ?1 LIMIT 1",
532
+ [slug],
533
+ )).rows[0];
534
+ return _hydrateFormRow(r);
535
+ }
536
+
537
+ async function listForms() {
538
+ var rows = (await query(
539
+ "SELECT * FROM storefront_forms WHERE archived_at IS NULL " +
540
+ "ORDER BY created_at ASC, slug ASC",
541
+ [],
542
+ )).rows;
543
+ return rows.map(_hydrateFormRow);
544
+ }
545
+
546
+ // -- updateForm ------------------------------------------------------
547
+
548
+ async function updateForm(slug, patch) {
549
+ _slug(slug);
550
+ if (!patch || typeof patch !== "object") {
551
+ throw new TypeError("storefrontForms.updateForm: patch object required");
552
+ }
553
+ var keys = Object.keys(patch);
554
+ if (!keys.length) {
555
+ throw new TypeError("storefrontForms.updateForm: patch must include at least one column");
556
+ }
557
+ var current = await getForm(slug);
558
+ if (!current) {
559
+ throw new TypeError("storefrontForms.updateForm: slug " + JSON.stringify(slug) + " not found");
560
+ }
561
+
562
+ var sets = [];
563
+ var params = [];
564
+ var idx = 1;
565
+
566
+ for (var i = 0; i < keys.length; i += 1) {
567
+ var col = keys[i];
568
+ if (ALLOWED_UPDATE_COLUMNS.indexOf(col) === -1) {
569
+ throw new TypeError("storefrontForms.updateForm: unsupported column " + JSON.stringify(col));
570
+ }
571
+ var v;
572
+ if (col === "title") { v = _line(patch[col], "title", MAX_TITLE_LEN); sets.push("title = ?" + idx); }
573
+ else if (col === "description") { v = _description(patch[col]); sets.push("description = ?" + idx); }
574
+ else if (col === "fields") { v = JSON.stringify(_fields(patch[col])); sets.push("fields_json = ?" + idx); }
575
+ else if (col === "submit_to") { v = JSON.stringify(_submitTo(patch[col])); sets.push("submit_to_json = ?" + idx); }
576
+ else if (col === "success_message"){ v = _successMessage(patch[col]); sets.push("success_message = ?" + idx); }
577
+ else /* throttle */ { v = _throttle(patch[col]); sets.push("throttle_per_minute_per_session = ?" + idx); }
578
+ params.push(v);
579
+ idx += 1;
580
+ }
581
+ sets.push("updated_at = ?" + idx);
582
+ params.push(_now());
583
+ idx += 1;
584
+ params.push(slug);
585
+
586
+ var r = await query(
587
+ "UPDATE storefront_forms SET " + sets.join(", ") + " WHERE slug = ?" + idx,
588
+ params,
589
+ );
590
+ if (Number(r.rowCount || 0) === 0) {
591
+ throw new TypeError("storefrontForms.updateForm: slug " + JSON.stringify(slug) + " not found");
592
+ }
593
+ return await getForm(slug);
594
+ }
595
+
596
+ // -- archiveForm -----------------------------------------------------
597
+
598
+ async function archiveForm(slug) {
599
+ _slug(slug);
600
+ var current = await getForm(slug);
601
+ if (!current) {
602
+ throw new TypeError("storefrontForms.archiveForm: slug " + JSON.stringify(slug) + " not found");
603
+ }
604
+ if (current.archived_at != null) {
605
+ // Already archived — idempotent. Return the current row.
606
+ return current;
607
+ }
608
+ var ts = _now();
609
+ await query(
610
+ "UPDATE storefront_forms SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
611
+ [ts, slug],
612
+ );
613
+ return await getForm(slug);
614
+ }
615
+
616
+ // -- throttleCheck ---------------------------------------------------
617
+ //
618
+ // Counts recent submissions for a (form_slug, session_id_hash) tuple
619
+ // over the last `THROTTLE_WINDOW_MS`. Returns `{ ok: true }` when
620
+ // under the limit, `{ ok: false, error: "throttled", count }` when
621
+ // over. Forms with `throttle_per_minute_per_session = 0` skip the
622
+ // check entirely. A missing session id falls back to "no throttle"
623
+ // because there's no key to bucket the submissions against.
624
+
625
+ async function throttleCheck(input) {
626
+ if (!input || typeof input !== "object") {
627
+ throw new TypeError("storefrontForms.throttleCheck: input object required");
628
+ }
629
+ var slug = _slug(input.form_slug);
630
+ var form = await getForm(slug);
631
+ if (!form) {
632
+ throw new TypeError("storefrontForms.throttleCheck: form " + JSON.stringify(slug) + " not found");
633
+ }
634
+ if (form.throttle_per_minute_per_session === 0) {
635
+ return { ok: true, limit: 0, count: 0 };
636
+ }
637
+ var sessionHash;
638
+ if (input.session_id_hash != null) {
639
+ sessionHash = String(input.session_id_hash);
640
+ } else if (input.session_id != null) {
641
+ sessionHash = _hashSession(String(input.session_id));
642
+ } else {
643
+ // No session key — operator-side caller chose not to bucket.
644
+ // Throttle is effectively disabled for this submission.
645
+ return { ok: true, limit: form.throttle_per_minute_per_session, count: 0 };
646
+ }
647
+ var windowStart = _now() - THROTTLE_WINDOW_MS;
648
+ var r = await query(
649
+ "SELECT COUNT(*) AS n FROM storefront_form_submissions " +
650
+ "WHERE form_slug = ?1 AND session_id_hash = ?2 AND created_at >= ?3",
651
+ [slug, sessionHash, windowStart],
652
+ );
653
+ var count = Number(r.rows[0] && r.rows[0].n || 0);
654
+ if (count >= form.throttle_per_minute_per_session) {
655
+ return { ok: false, error: "throttled", limit: form.throttle_per_minute_per_session, count: count };
656
+ }
657
+ return { ok: true, limit: form.throttle_per_minute_per_session, count: count };
658
+ }
659
+
660
+ // -- submit ----------------------------------------------------------
661
+
662
+ async function submit(input) {
663
+ if (!input || typeof input !== "object") {
664
+ throw new TypeError("storefrontForms.submit: input object required");
665
+ }
666
+ var slug = _slug(input.form_slug);
667
+ var form = await getForm(slug);
668
+ if (!form) {
669
+ throw new TypeError("storefrontForms.submit: form " + JSON.stringify(slug) + " not found");
670
+ }
671
+ if (form.archived_at != null) {
672
+ return { ok: false, error: "archived" };
673
+ }
674
+ if (!input.values || typeof input.values !== "object" || Array.isArray(input.values)) {
675
+ throw new TypeError("storefrontForms.submit: values must be a plain object");
676
+ }
677
+
678
+ // Refuse unknown field names so a hostile submitter can't smuggle
679
+ // an arbitrary key into the stored JSON. The set of allowed
680
+ // names is the form's declared fields list.
681
+ var allowed = {};
682
+ for (var ai = 0; ai < form.fields.length; ai += 1) allowed[form.fields[ai].name] = true;
683
+ var inputKeys = Object.keys(input.values);
684
+ for (var ki = 0; ki < inputKeys.length; ki += 1) {
685
+ if (!Object.prototype.hasOwnProperty.call(allowed, inputKeys[ki])) {
686
+ return { ok: false, error: "unknown field " + JSON.stringify(inputKeys[ki]) };
687
+ }
688
+ }
689
+
690
+ // Per-field validation. Aggregate every failure so the caller
691
+ // can render all of them at once.
692
+ var cleanedValues = {};
693
+ var fieldErrors = {};
694
+ for (var i = 0; i < form.fields.length; i += 1) {
695
+ var f = form.fields[i];
696
+ var raw = Object.prototype.hasOwnProperty.call(input.values, f.name) ? input.values[f.name] : null;
697
+ var res = _validateValue(f, raw);
698
+ if (!res.ok) {
699
+ fieldErrors[f.name] = res.error;
700
+ continue;
701
+ }
702
+ if (res.value !== null) cleanedValues[f.name] = res.value;
703
+ }
704
+ var errorNames = Object.keys(fieldErrors);
705
+ if (errorNames.length) {
706
+ return { ok: false, error: "field_validation", field_errors: fieldErrors };
707
+ }
708
+
709
+ // Resolve session id hash for throttle bucketing + persistence.
710
+ var sessionHash = null;
711
+ if (input.session_id_hash != null) {
712
+ sessionHash = String(input.session_id_hash);
713
+ } else if (input.session_id != null) {
714
+ sessionHash = _hashSession(String(input.session_id));
715
+ }
716
+
717
+ // Throttle check — only enforced when both a session key + a
718
+ // non-zero per-form limit exist.
719
+ if (sessionHash && form.throttle_per_minute_per_session > 0) {
720
+ var windowStart = _now() - THROTTLE_WINDOW_MS;
721
+ var rt = await query(
722
+ "SELECT COUNT(*) AS n FROM storefront_form_submissions " +
723
+ "WHERE form_slug = ?1 AND session_id_hash = ?2 AND created_at >= ?3",
724
+ [slug, sessionHash, windowStart],
725
+ );
726
+ var count = Number(rt.rows[0] && rt.rows[0].n || 0);
727
+ if (count >= form.throttle_per_minute_per_session) {
728
+ return { ok: false, error: "throttled", limit: form.throttle_per_minute_per_session, count: count };
729
+ }
730
+ }
731
+
732
+ var ipHash = _ipHash(input.ip_hash == null ? null : input.ip_hash);
733
+ var uaClass = _uaClass(input.ua_class == null ? null : input.ua_class);
734
+
735
+ // Persist FIRST so a dispatch failure can't lose the submission.
736
+ // The dispatch outcome (success/failure) is stamped via UPDATE
737
+ // after the dep call returns.
738
+ var id = _b().uuid.v7();
739
+ var now = _now();
740
+ await query(
741
+ "INSERT INTO storefront_form_submissions " +
742
+ "(id, form_slug, values_json, session_id_hash, ip_hash, ua_class, dispatched_at, dispatch_error, created_at) " +
743
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL, ?7)",
744
+ [id, slug, JSON.stringify(cleanedValues), sessionHash, ipHash, uaClass, now],
745
+ );
746
+
747
+ // Dispatch via the matching injected dep. Both branches stamp
748
+ // `dispatched_at` on success or `dispatch_error` on failure. A
749
+ // missing dep means the submission lands but stays
750
+ // un-dispatched — operator review surfaces the row with
751
+ // `dispatched_at IS NULL`.
752
+ var dispatched = false;
753
+ var dispatchError = null;
754
+ var target = form.submit_to;
755
+ try {
756
+ if (target.kind === "email" && notifications && typeof notifications.enqueue === "function") {
757
+ var enq = await notifications.enqueue({
758
+ recipient_id: target.value,
759
+ channel: "email",
760
+ event_type: "storefront.form_submission",
761
+ title: form.title,
762
+ body: "Form " + form.slug + " received a new submission.",
763
+ payload: { form_slug: form.slug, submission_id: id, values: cleanedValues },
764
+ });
765
+ if (enq && enq.ok === false) {
766
+ dispatchError = "notifications: " + (enq.error || "rejected");
767
+ } else {
768
+ dispatched = true;
769
+ }
770
+ } else if (target.kind === "webhook" && webhooks && typeof webhooks.send === "function") {
771
+ await webhooks.send(target.value, {
772
+ form_slug: form.slug,
773
+ submission_id: id,
774
+ values: cleanedValues,
775
+ occurred_at: now,
776
+ });
777
+ dispatched = true;
778
+ }
779
+ } catch (e) {
780
+ dispatchError = String((e && e.message) || e).slice(0, 1024);
781
+ }
782
+
783
+ if (dispatched) {
784
+ await query(
785
+ "UPDATE storefront_form_submissions SET dispatched_at = ?1 WHERE id = ?2",
786
+ [_now(), id],
787
+ );
788
+ } else if (dispatchError) {
789
+ await query(
790
+ "UPDATE storefront_form_submissions SET dispatch_error = ?1 WHERE id = ?2",
791
+ [dispatchError, id],
792
+ );
793
+ }
794
+
795
+ return {
796
+ ok: true,
797
+ id: id,
798
+ dispatched: dispatched,
799
+ dispatch_error: dispatchError,
800
+ success_message: form.success_message,
801
+ };
802
+ }
803
+
804
+ // -- submissionsForForm ---------------------------------------------
805
+
806
+ async function submissionsForForm(input) {
807
+ if (!input || typeof input !== "object") {
808
+ throw new TypeError("storefrontForms.submissionsForForm: input object required");
809
+ }
810
+ var slug = _slug(input.form_slug);
811
+ var limit = _listLimit(input.limit);
812
+ var rows;
813
+ if (input.cursor != null) {
814
+ if (typeof input.cursor !== "string" || !input.cursor.length || input.cursor.length > 256) {
815
+ throw new TypeError("storefrontForms.submissionsForForm: cursor must be a non-empty string ≤ 256 chars");
816
+ }
817
+ // The cursor is a `<created_at>:<id>` pair encoded by the
818
+ // previous call. The encoding is the simplest stable form
819
+ // that lets the next page resume at the strict (<,<=)
820
+ // boundary without an HMAC step — the cursor surface is
821
+ // operator-internal and the tuple-comparison is total
822
+ // (created_at DESC, id DESC).
823
+ var sep = input.cursor.indexOf(":");
824
+ if (sep <= 0) {
825
+ throw new TypeError("storefrontForms.submissionsForForm: cursor malformed");
826
+ }
827
+ var cAt = Number(input.cursor.slice(0, sep));
828
+ var cId = input.cursor.slice(sep + 1);
829
+ if (!Number.isFinite(cAt)) {
830
+ throw new TypeError("storefrontForms.submissionsForForm: cursor malformed");
831
+ }
832
+ rows = (await query(
833
+ "SELECT * FROM storefront_form_submissions " +
834
+ "WHERE form_slug = ?1 AND (created_at < ?2 OR (created_at = ?2 AND id < ?3)) " +
835
+ "ORDER BY created_at DESC, id DESC LIMIT ?4",
836
+ [slug, cAt, cId, limit],
837
+ )).rows;
838
+ } else {
839
+ rows = (await query(
840
+ "SELECT * FROM storefront_form_submissions WHERE form_slug = ?1 " +
841
+ "ORDER BY created_at DESC, id DESC LIMIT ?2",
842
+ [slug, limit],
843
+ )).rows;
844
+ }
845
+ var items = rows.map(_hydrateSubmissionRow);
846
+ var next = null;
847
+ if (items.length === limit) {
848
+ var last = items[items.length - 1];
849
+ next = last.created_at + ":" + last.id;
850
+ }
851
+ return { items: items, next_cursor: next };
852
+ }
853
+
854
+ return {
855
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
856
+ FIELD_KINDS: FIELD_KINDS,
857
+ SUBMIT_TO_KINDS: SUBMIT_TO_KINDS,
858
+ THROTTLE_WINDOW_MS: THROTTLE_WINDOW_MS,
859
+
860
+ hashSession: function (sessionId) {
861
+ if (typeof sessionId !== "string" || !sessionId.length) {
862
+ throw new TypeError("storefrontForms.hashSession: sessionId must be a non-empty string");
863
+ }
864
+ return _hashSession(sessionId);
865
+ },
866
+
867
+ defineForm: defineForm,
868
+ getForm: getForm,
869
+ listForms: listForms,
870
+ updateForm: updateForm,
871
+ archiveForm: archiveForm,
872
+ submit: submit,
873
+ submissionsForForm: submissionsForForm,
874
+ throttleCheck: throttleCheck,
875
+ };
876
+ }
877
+
878
+ module.exports = {
879
+ create: create,
880
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
881
+ FIELD_KINDS: FIELD_KINDS,
882
+ SUBMIT_TO_KINDS: SUBMIT_TO_KINDS,
883
+ THROTTLE_WINDOW_MS: THROTTLE_WINDOW_MS,
884
+ };