@reddoorla/maintenance 0.66.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/airtable-33SY2UBZ.js +9 -0
  2. package/dist/{announce-YPXVHNNP.js → announce-74ZIAV26.js} +4 -4
  3. package/dist/{bump-deps-INDDTDSG.js → bump-deps-HYBYNPMY.js} +2 -2
  4. package/dist/{chunk-LISJ3K3R.js → chunk-7MJJEEII.js} +2 -2
  5. package/dist/{chunk-4P6UQG5L.js → chunk-CL5DYUWQ.js} +4 -1
  6. package/dist/chunk-CL5DYUWQ.js.map +1 -0
  7. package/dist/{chunk-XKKTK6WA.js → chunk-HIN3P4ZN.js} +23 -2
  8. package/dist/chunk-HIN3P4ZN.js.map +1 -0
  9. package/dist/{chunk-CH6K2VMM.js → chunk-JDGIO54P.js} +2 -2
  10. package/dist/{chunk-JD3X3QMP.js → chunk-KN5OC5YH.js} +3 -3
  11. package/dist/{chunk-XKSKHHSX.js → chunk-OAMJINM5.js} +3 -3
  12. package/dist/{chunk-G2YRXV26.js → chunk-OTBA7DON.js} +2 -2
  13. package/dist/{chunk-PTPTVVY7.js → chunk-PH3AGF4Z.js} +2 -2
  14. package/dist/{chunk-YBYWNUT3.js → chunk-T34EUM5B.js} +2 -2
  15. package/dist/{chunk-G35B5U6F.js → chunk-ULL4AD3Y.js} +2 -2
  16. package/dist/{chunk-NMGGP5WN.js → chunk-Y4O44LZB.js} +2 -2
  17. package/dist/{chunk-CWSQZ27M.js → chunk-YKOOAWVL.js} +3 -3
  18. package/dist/cli/bin.js +15 -15
  19. package/dist/cli/commands/audit.js +8 -8
  20. package/dist/client-NVVQK3HH.js +11 -0
  21. package/dist/{convert-to-pnpm-4GD4667L.js → convert-to-pnpm-5MPZWFHM.js} +2 -2
  22. package/dist/{db-YBLQR2YY.js → db-3QW2XEXA.js} +3 -3
  23. package/dist/{digest-SIZUXT67.js → digest-MECMZPFE.js} +3 -3
  24. package/dist/forms/index.d.ts +13 -0
  25. package/dist/forms/index.js +28 -2
  26. package/dist/forms/index.js.map +1 -1
  27. package/dist/{github-signals-3O64PFWP.js → github-signals-VWBMQNLH.js} +7 -7
  28. package/dist/index.d.ts +12 -1
  29. package/dist/index.js +27 -15
  30. package/dist/index.js.map +1 -1
  31. package/dist/{init-KMWXJZEX.js → init-X3BCJAUG.js} +2 -2
  32. package/dist/{launch-5KRCKFYM.js → launch-NQCL5K5H.js} +5 -5
  33. package/dist/{lighthouse-airtable-5JICCZND.js → lighthouse-airtable-QUNOSDT5.js} +3 -3
  34. package/dist/migrate-35DNNF3H.js +7 -0
  35. package/dist/{onboard-RCBXS5B3.js → onboard-7JOBZY35.js} +2 -2
  36. package/dist/{orchestrate-O4KR6JME.js → orchestrate-JQX72RJE.js} +6 -6
  37. package/dist/{renovate-dispatch-ABRSIT77.js → renovate-dispatch-PWXFZNHN.js} +2 -2
  38. package/dist/{report-LSGTMXQJ.js → report-QUKNKUUE.js} +5 -5
  39. package/dist/{self-updating-CPMY3HFY.js → self-updating-OKJKU2FE.js} +2 -2
  40. package/dist/{selftest-7Y5K4PYL.js → selftest-6JVYR24F.js} +8 -8
  41. package/dist/{svelte-codemods-GJRTBLJE.js → svelte-codemods-GG6EEES5.js} +2 -2
  42. package/dist/{sync-configs-NYJNSIRN.js → sync-configs-UFHZZPL7.js} +2 -2
  43. package/dist/{upgrade-LVYRDKDL.js → upgrade-S25N3473.js} +2 -2
  44. package/dist/{websites-5ETIXB7F.js → websites-U3UZS5VG.js} +2 -2
  45. package/dist/{write-audits-to-airtable-GHR337N7.js → write-audits-to-airtable-GAJUCSTY.js} +4 -4
  46. package/package.json +1 -1
  47. package/dist/airtable-M4AQHRNV.js +0 -9
  48. package/dist/chunk-4P6UQG5L.js.map +0 -1
  49. package/dist/chunk-XKKTK6WA.js.map +0 -1
  50. package/dist/client-C2GOZD2D.js +0 -11
  51. package/dist/migrate-BFOG2OCP.js +0 -7
  52. /package/dist/{airtable-M4AQHRNV.js.map → airtable-33SY2UBZ.js.map} +0 -0
  53. /package/dist/{announce-YPXVHNNP.js.map → announce-74ZIAV26.js.map} +0 -0
  54. /package/dist/{bump-deps-INDDTDSG.js.map → bump-deps-HYBYNPMY.js.map} +0 -0
  55. /package/dist/{chunk-LISJ3K3R.js.map → chunk-7MJJEEII.js.map} +0 -0
  56. /package/dist/{chunk-CH6K2VMM.js.map → chunk-JDGIO54P.js.map} +0 -0
  57. /package/dist/{chunk-JD3X3QMP.js.map → chunk-KN5OC5YH.js.map} +0 -0
  58. /package/dist/{chunk-XKSKHHSX.js.map → chunk-OAMJINM5.js.map} +0 -0
  59. /package/dist/{chunk-G2YRXV26.js.map → chunk-OTBA7DON.js.map} +0 -0
  60. /package/dist/{chunk-PTPTVVY7.js.map → chunk-PH3AGF4Z.js.map} +0 -0
  61. /package/dist/{chunk-YBYWNUT3.js.map → chunk-T34EUM5B.js.map} +0 -0
  62. /package/dist/{chunk-G35B5U6F.js.map → chunk-ULL4AD3Y.js.map} +0 -0
  63. /package/dist/{chunk-NMGGP5WN.js.map → chunk-Y4O44LZB.js.map} +0 -0
  64. /package/dist/{chunk-CWSQZ27M.js.map → chunk-YKOOAWVL.js.map} +0 -0
  65. /package/dist/{client-C2GOZD2D.js.map → client-NVVQK3HH.js.map} +0 -0
  66. /package/dist/{convert-to-pnpm-4GD4667L.js.map → convert-to-pnpm-5MPZWFHM.js.map} +0 -0
  67. /package/dist/{db-YBLQR2YY.js.map → db-3QW2XEXA.js.map} +0 -0
  68. /package/dist/{digest-SIZUXT67.js.map → digest-MECMZPFE.js.map} +0 -0
  69. /package/dist/{github-signals-3O64PFWP.js.map → github-signals-VWBMQNLH.js.map} +0 -0
  70. /package/dist/{init-KMWXJZEX.js.map → init-X3BCJAUG.js.map} +0 -0
  71. /package/dist/{launch-5KRCKFYM.js.map → launch-NQCL5K5H.js.map} +0 -0
  72. /package/dist/{lighthouse-airtable-5JICCZND.js.map → lighthouse-airtable-QUNOSDT5.js.map} +0 -0
  73. /package/dist/{migrate-BFOG2OCP.js.map → migrate-35DNNF3H.js.map} +0 -0
  74. /package/dist/{onboard-RCBXS5B3.js.map → onboard-7JOBZY35.js.map} +0 -0
  75. /package/dist/{orchestrate-O4KR6JME.js.map → orchestrate-JQX72RJE.js.map} +0 -0
  76. /package/dist/{renovate-dispatch-ABRSIT77.js.map → renovate-dispatch-PWXFZNHN.js.map} +0 -0
  77. /package/dist/{report-LSGTMXQJ.js.map → report-QUKNKUUE.js.map} +0 -0
  78. /package/dist/{self-updating-CPMY3HFY.js.map → self-updating-OKJKU2FE.js.map} +0 -0
  79. /package/dist/{selftest-7Y5K4PYL.js.map → selftest-6JVYR24F.js.map} +0 -0
  80. /package/dist/{svelte-codemods-GJRTBLJE.js.map → svelte-codemods-GG6EEES5.js.map} +0 -0
  81. /package/dist/{sync-configs-NYJNSIRN.js.map → sync-configs-UFHZZPL7.js.map} +0 -0
  82. /package/dist/{upgrade-LVYRDKDL.js.map → upgrade-S25N3473.js.map} +0 -0
  83. /package/dist/{websites-5ETIXB7F.js.map → websites-U3UZS5VG.js.map} +0 -0
  84. /package/dist/{write-audits-to-airtable-GHR337N7.js.map → write-audits-to-airtable-GAJUCSTY.js.map} +0 -0
@@ -8,7 +8,7 @@ import {
8
8
  diffAttention,
9
9
  readDigestState,
10
10
  writeDigestState
11
- } from "./chunk-CH6K2VMM.js";
11
+ } from "./chunk-JDGIO54P.js";
12
12
  import {
13
13
  isIdempotencyConflict
14
14
  } from "./chunk-OGGRFBL4.js";
@@ -20,7 +20,7 @@ import {
20
20
  import {
21
21
  listWebsites,
22
22
  siteSlug
23
- } from "./chunk-4P6UQG5L.js";
23
+ } from "./chunk-CL5DYUWQ.js";
24
24
  import {
25
25
  openBase,
26
26
  readAirtableConfig
@@ -226,4 +226,4 @@ export {
226
226
  renderDigestHtml,
227
227
  runDigest
228
228
  };
229
- //# sourceMappingURL=digest-SIZUXT67.js.map
229
+ //# sourceMappingURL=digest-MECMZPFE.js.map
@@ -2,6 +2,13 @@ import { F as FormType } from '../types-RXY-vY-5.js';
2
2
  export { S as SUBMISSION_FORM_TYPES } from '../types-RXY-vY-5.js';
3
3
  import { RequestEvent, ActionFailure } from '@sveltejs/kit';
4
4
 
5
+ /** The reserved wire envelope a fleet site forwards alongside the lead fields. */
6
+ type SubmissionMeta = {
7
+ turnstileToken?: string;
8
+ clientIp?: string;
9
+ userAgent?: string;
10
+ };
11
+
5
12
  /**
6
13
  * The JSON a fleet site forwards to the dashboard ingest endpoint. Typed fields
7
14
  * are optional; the index signature lets a site include its own extra fields
@@ -22,6 +29,8 @@ type SubmissionPayload = {
22
29
  message?: string | undefined;
23
30
  sourceUrl?: string | undefined;
24
31
  utm?: string | undefined;
32
+ /** Reserved transport envelope (token/IP/UA); stripped centrally, never persisted. */
33
+ _meta?: SubmissionMeta | undefined;
25
34
  [key: string]: unknown;
26
35
  };
27
36
  type IngestClientResult = {
@@ -100,6 +109,8 @@ type CreateIngestActionOptions = {
100
109
  botFieldName?: string;
101
110
  /** Hidden timestamp input name (planted in `load`). Default "ts". */
102
111
  tsFieldName?: string;
112
+ /** Field carrying the Cloudflare Turnstile token. Default "cf-turnstile-response". */
113
+ turnstileFieldName?: string;
103
114
  /** fail(500) copy when env vars are unset. */
104
115
  unavailableMessage?: string;
105
116
  /** fail(502) copy when the ingest endpoint rejects/errors. */
@@ -140,6 +151,8 @@ type CreateIngestEndpointOptions = {
140
151
  formType?: string;
141
152
  /** Honeypot field name in the JSON body. Default "bot-field". */
142
153
  botFieldName?: string;
154
+ /** Field carrying the Cloudflare Turnstile token. Default "cf-turnstile-response". */
155
+ turnstileFieldName?: string;
143
156
  /** json(500) copy when env vars are unset. */
144
157
  unavailableMessage?: string;
145
158
  /** json(400/502) copy for bad input / ingest failure. */
@@ -58,9 +58,29 @@ function screenSubmission(input) {
58
58
 
59
59
  // src/forms/action.ts
60
60
  import { fail, redirect } from "@sveltejs/kit";
61
+
62
+ // src/forms/meta.ts
63
+ function buildSubmissionMeta(event, turnstileToken) {
64
+ const meta = {};
65
+ const token = typeof turnstileToken === "string" ? turnstileToken.trim() : "";
66
+ if (token) meta.turnstileToken = token;
67
+ if (typeof event.getClientAddress === "function") {
68
+ try {
69
+ const ip = event.getClientAddress();
70
+ if (typeof ip === "string" && ip.trim()) meta.clientIp = ip.trim();
71
+ } catch {
72
+ }
73
+ }
74
+ const ua = event.request?.headers?.get?.("user-agent");
75
+ if (typeof ua === "string" && ua.trim()) meta.userAgent = ua.trim();
76
+ return Object.keys(meta).length > 0 ? meta : void 0;
77
+ }
78
+
79
+ // src/forms/action.ts
61
80
  function createIngestAction(opts) {
62
81
  const botFieldName = opts.botFieldName ?? "bot-field";
63
82
  const tsFieldName = opts.tsFieldName ?? "ts";
83
+ const turnstileFieldName = opts.turnstileFieldName ?? "cf-turnstile-response";
64
84
  const now = opts.now ?? Date.now;
65
85
  const unavailable = opts.unavailableMessage ?? "This form is temporarily unavailable. Please email us directly.";
66
86
  const failed = opts.errorMessage ?? "Something went wrong sending your message. Please try again.";
@@ -97,7 +117,11 @@ function createIngestAction(opts) {
97
117
  url,
98
118
  token,
99
119
  fetch: event.fetch,
100
- payload: { ...opts.buildPayload(form, event), formType: opts.formType }
120
+ payload: {
121
+ ...opts.buildPayload(form, event),
122
+ formType: opts.formType,
123
+ _meta: buildSubmissionMeta(event, form.get(turnstileFieldName)?.toString())
124
+ }
101
125
  });
102
126
  if (!result.ok) {
103
127
  console.error(`[forms-ingest] ${opts.formType} \u2192 ${result.status}: ${result.error}`);
@@ -126,6 +150,7 @@ function str(v) {
126
150
  }
127
151
  function createIngestEndpoint(opts) {
128
152
  const botFieldName = opts.botFieldName ?? "bot-field";
153
+ const turnstileFieldName = opts.turnstileFieldName ?? "cf-turnstile-response";
129
154
  const unavailable = opts.unavailableMessage ?? "This form is temporarily unavailable. Please email us directly.";
130
155
  const failed = opts.errorMessage ?? "Something went wrong sending your message. Please try again.";
131
156
  return async (event) => {
@@ -155,7 +180,8 @@ function createIngestEndpoint(opts) {
155
180
  try {
156
181
  payload = {
157
182
  ...opts.buildPayload(body, event),
158
- ...opts.formType ? { formType: opts.formType } : {}
183
+ ...opts.formType ? { formType: opts.formType } : {},
184
+ _meta: buildSubmissionMeta(event, str(body[turnstileFieldName]))
159
185
  };
160
186
  } catch (err) {
161
187
  console.error(`[forms-ingest] buildPayload threw: ${String(err)}`);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/forms/client.ts","../../src/forms/action.ts","../../src/forms/endpoint.ts"],"sourcesContent":["import { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\n\n/**\n * The JSON a fleet site forwards to the dashboard ingest endpoint. Typed fields\n * are optional; the index signature lets a site include its own extra fields\n * (e.g. `company`) which the dashboard normalizer captures into `extraFields`.\n *\n * Each typed field allows `string | undefined` (not just `string`) so a\n * `buildPayload` mapping can use the idiomatic `form.get(\"name\")?.toString()`\n * pattern under `exactOptionalPropertyTypes` without a cast — an absent field\n * and an explicit `undefined` both serialize away in the JSON body.\n */\nexport type SubmissionPayload = {\n formType?: FormType | string | undefined;\n name?: string | undefined;\n firstName?: string | undefined;\n lastName?: string | undefined;\n email?: string | undefined;\n phone?: string | undefined;\n message?: string | undefined;\n sourceUrl?: string | undefined;\n utm?: string | undefined;\n [key: string]: unknown;\n};\n\nexport type IngestClientResult =\n | { ok: true; id: string }\n | { ok: false; status: number; error: string };\n\nexport type SubmitToIngestOptions = {\n /** Full ingest endpoint incl. the site slug, e.g. https://…/api/forms/reddoor */\n url: string;\n /** The shared FORMS_INGEST_TOKEN. */\n token: string;\n payload: SubmissionPayload;\n /** Injectable fetch (pass SvelteKit's `event.fetch`); defaults to global fetch. */\n fetch?: typeof fetch;\n};\n\n/**\n * Forward a submission to the dashboard ingest endpoint. Never throws — a network\n * failure or a non-2xx response is returned as `{ ok: false }` so the caller can\n * show a friendly error rather than a 500.\n */\nexport async function submitToIngest(opts: SubmitToIngestOptions): Promise<IngestClientResult> {\n const doFetch = opts.fetch ?? fetch;\n let res: Response;\n try {\n res = await doFetch(opts.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"x-forms-token\": opts.token },\n body: JSON.stringify(opts.payload),\n });\n } catch (err) {\n return { ok: false, status: 0, error: `network error: ${String(err)}` };\n }\n let body: unknown = null;\n try {\n body = await res.json();\n } catch {\n // non-JSON response — fall through to the error path\n }\n const obj = body && typeof body === \"object\" ? (body as Record<string, unknown>) : null;\n if (res.ok && obj && obj.ok === true) {\n return { ok: true, id: String(obj.id ?? \"\") };\n }\n const error = obj && typeof obj.error === \"string\" ? obj.error : `ingest failed (${res.status})`;\n return { ok: false, status: res.status, error };\n}\n\nexport type SubmitScreenOutOptions = {\n /** Same ingest endpoint the site already posts submissions to. */\n url: string;\n token: string;\n reason: \"honeypot\" | \"too-fast\";\n fetch?: typeof fetch;\n /** Abort budget so a slow/hung beacon can't delay the (already-successful) response. */\n timeoutMs?: number;\n};\n\n/**\n * Best-effort screen-out beacon: tells the central ingest \"a bot was screened here\"\n * (no PII) so caught-vs-delivered is observable. Never throws — a failure is returned\n * as { ok: false } and the caller ignores it (the visitor already saw success).\n */\nexport async function submitScreenOut(\n opts: SubmitScreenOutOptions,\n): Promise<{ ok: boolean; status: number }> {\n const doFetch = opts.fetch ?? fetch;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1500);\n try {\n const res = await doFetch(opts.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"x-forms-token\": opts.token },\n body: JSON.stringify({ screenOut: opts.reason }),\n signal: controller.signal,\n });\n return { ok: res.ok, status: res.status };\n } catch {\n return { ok: false, status: 0 };\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport type ScreenInput = { botField?: string | null; elapsedMs?: number | null };\nexport type ScreenResult = { ok: true } | { ok: false; reason: \"honeypot\" | \"too-fast\" };\n\n/**\n * Minimum plausible fill time; faster than this reads as a bot. Kept low (800ms)\n * on purpose: a too-fast fill is dropped *silently* (the visitor sees success),\n * so a real human who happens to be quick — autofill, a short form, a returning\n * visitor — would lose their lead with no trace. Below this, a submit is\n * effectively instant (page render → fill → click → network all under ~0.8s),\n * which a human realistically never beats but a script does. The honeypot is the\n * primary bot signal; this is the secondary one, so it errs toward letting\n * borderline-fast humans through.\n */\nexport const MIN_FILL_MS = 800;\n\n/**\n * Cheap bot screen for the site action. A filled honeypot is a bot; a submission\n * faster than MIN_FILL_MS is a bot. Missing timing data (null/undefined) is NOT a\n * rejection — a prerendered/cached page can't plant a fresh timestamp, and the\n * honeypot remains the primary signal.\n *\n * Any numeric elapsed BELOW the floor — including a NEGATIVE value — is too-fast. A\n * negative elapsed time is impossible for a real fill: it means the client posted a\n * FUTURE timestamp. The earlier `>= 0` guard let that slip through (negative skipped\n * the `< MIN_FILL_MS` branch and returned ok), silently bypassing the timing gate.\n */\nexport function screenSubmission(input: ScreenInput): ScreenResult {\n if (typeof input.botField === \"string\" && input.botField.trim().length > 0) {\n return { ok: false, reason: \"honeypot\" };\n }\n if (typeof input.elapsedMs === \"number\" && input.elapsedMs < MIN_FILL_MS) {\n return { ok: false, reason: \"too-fast\" };\n }\n return { ok: true };\n}\n\nexport { SUBMISSION_FORM_TYPES, type FormType };\n","import { fail, redirect, type ActionFailure, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\n\n/** Endpoint + token for the dashboard ingest, read per-request from site env. */\nexport type IngestActionConfig = { url?: string; token?: string };\n\nexport type CreateIngestActionOptions = {\n /** Stamped onto every payload as `formType` (a SUBMISSION_FORM_TYPES value). */\n formType: string;\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map this form's fields to a payload. The factory's `formType` is always\n * authoritative and cannot be overridden by `buildPayload`.\n */\n buildPayload: (form: FormData, event: RequestEvent) => SubmissionPayload;\n /** Honeypot input name. Default \"bot-field\". */\n botFieldName?: string;\n /** Hidden timestamp input name (planted in `load`). Default \"ts\". */\n tsFieldName?: string;\n /** fail(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** fail(502) copy when the ingest endpoint rejects/errors. */\n errorMessage?: string;\n /** Injectable clock for tests. Default Date.now. */\n now?: () => number;\n /** If set, a successful OR bot-screened submission throws redirect(303, redirectTo)\n * instead of returning {success:true} (e.g. a dedicated /thank-you page). */\n redirectTo?: string;\n};\n\nexport type IngestActionData = { success: true } | ActionFailure<{ error: string }>;\n\n/**\n * Build a SvelteKit `default` form action that screens for bots, forwards the\n * submission to the dashboard ingest endpoint, and returns SvelteKit-shaped\n * results. The per-form field mapping is the only thing a site must supply.\n */\nexport function createIngestAction(\n opts: CreateIngestActionOptions,\n): (event: RequestEvent) => Promise<IngestActionData> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const tsFieldName = opts.tsFieldName ?? \"ts\";\n const now = opts.now ?? Date.now;\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let form: FormData;\n try {\n form = await event.request.formData();\n } catch {\n console.error(`[forms-ingest] ${opts.formType}: could not parse form body`);\n return fail(400, { error: failed });\n }\n\n // Bot screen: a filled honeypot OR an implausibly fast fill is silently\n // accepted (return success, do NOT forward) so bots get no signal.\n const screen = screenSubmission({\n botField: form.get(botFieldName)?.toString() ?? null,\n elapsedMs: elapsedMs(form.get(tsFieldName), now),\n });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // succeed exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return succeed();\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${opts.formType}`);\n return fail(500, { error: unavailable });\n }\n\n const result = await submitToIngest({\n url,\n token,\n fetch: event.fetch,\n payload: { ...opts.buildPayload(form, event), formType: opts.formType },\n });\n if (!result.ok) {\n console.error(`[forms-ingest] ${opts.formType} → ${result.status}: ${result.error}`);\n return fail(502, { error: failed });\n }\n return succeed();\n };\n\n // Single success path: redirect when configured (e.g. a dedicated /thank-you\n // page), otherwise return the SvelteKit-shaped success. `redirect()` throws, so\n // the trailing `return` keeps the `{ success: true }` type.\n function succeed(): { success: true } {\n if (opts.redirectTo) redirect(303, opts.redirectTo);\n return { success: true };\n }\n}\n\n// `FormDataEntryValue` is a DOM-lib global; this package compiles with only the\n// ES2022 lib + @types/node, where it is not in scope. Derive the type from the\n// in-scope `FormData.get` return instead — same value, no DOM-lib dependency.\nfunction elapsedMs(tsRaw: ReturnType<FormData[\"get\"]>, now: () => number): number | null {\n const ts = Number(tsRaw);\n if (!Number.isFinite(ts) || ts <= 0) return null;\n // Clamp at 0: elapsed time can't be negative. A future `ts` (clock skew or a bot\n // forging one) would otherwise yield a negative value; clamping makes it read as\n // 0ms (effectively instant) so the MIN_FILL_MS gate still trips. Defense-in-depth\n // with screenSubmission, which also treats any sub-floor elapsed as too-fast.\n return Math.max(0, now() - ts);\n}\n","import { json, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\nimport { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\nimport type { IngestActionConfig } from \"./action.js\";\n\n/**\n * Options for {@link createIngestEndpoint} — the JSON sibling of\n * `createIngestAction` for client-driven forms (modals / lightboxes / fetch)\n * that POST JSON to a `+server.ts` route instead of using a form action.\n */\nexport type CreateIngestEndpointOptions = {\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map the parsed JSON body to a payload. Must set `formType` UNLESS the fixed\n * `formType` option is provided (then that is authoritative and overrides it).\n */\n buildPayload: (body: Record<string, unknown>, event: RequestEvent) => SubmissionPayload;\n /** Fixed formType for single-type endpoints; omit for multi-type endpoints\n * where `buildPayload` derives formType from the body. */\n formType?: string;\n /** Honeypot field name in the JSON body. Default \"bot-field\". */\n botFieldName?: string;\n /** json(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** json(400/502) copy for bad input / ingest failure. */\n errorMessage?: string;\n};\n\nfunction isFormType(v: unknown): v is FormType {\n return typeof v === \"string\" && (SUBMISSION_FORM_TYPES as readonly string[]).includes(v);\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" ? v : undefined;\n}\n\n/**\n * Build a JSON `POST` handler that screens for bots, forwards the submission to\n * the dashboard ingest endpoint, and returns `{ ok }`-shaped JSON. The per-form\n * field mapping (`buildPayload`) is the only thing a site must supply. The\n * returned function is structurally a SvelteKit `RequestHandler`.\n */\nexport function createIngestEndpoint(\n opts: CreateIngestEndpointOptions,\n): (event: RequestEvent) => Promise<Response> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await event.request.json();\n if (!parsed || typeof parsed !== \"object\") throw new Error(\"body is not an object\");\n body = parsed as Record<string, unknown>;\n } catch {\n console.error(\"[forms-ingest] could not parse JSON body\");\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n // Bot screen: honeypot only. A client POST carries no server-planted ts, and\n // screenSubmission treats a missing elapsedMs as OK. A filled honeypot is\n // silently accepted (return ok, do NOT forward) so bots get no signal.\n const screen = screenSubmission({ botField: str(body[botFieldName]) ?? null });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // return success exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return json({ ok: true });\n }\n\n // buildPayload runs on untrusted JSON; a careless field access (e.g.\n // `body.name.trim()` on a non-string) would otherwise escape as a 500. Treat\n // a throw as a malformed request (400), keeping the \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...opts.buildPayload(body, event),\n ...(opts.formType ? { formType: opts.formType } : {}),\n };\n } catch (err) {\n console.error(`[forms-ingest] buildPayload threw: ${String(err)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n if (!isFormType(payload.formType)) {\n console.error(`[forms-ingest] invalid formType: ${String(payload.formType)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${payload.formType}`);\n return json({ ok: false, error: unavailable }, { status: 500 });\n }\n\n const result = await submitToIngest({ url, token, fetch: event.fetch, payload });\n if (!result.ok) {\n console.error(`[forms-ingest] ${payload.formType} → ${result.status}: ${result.error}`);\n return json({ ok: false, error: failed }, { status: 502 });\n }\n return json({ ok: true });\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/forms/client.ts","../../src/forms/action.ts","../../src/forms/meta.ts","../../src/forms/endpoint.ts"],"sourcesContent":["import { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\nimport type { SubmissionMeta } from \"./meta.js\";\n\n/**\n * The JSON a fleet site forwards to the dashboard ingest endpoint. Typed fields\n * are optional; the index signature lets a site include its own extra fields\n * (e.g. `company`) which the dashboard normalizer captures into `extraFields`.\n *\n * Each typed field allows `string | undefined` (not just `string`) so a\n * `buildPayload` mapping can use the idiomatic `form.get(\"name\")?.toString()`\n * pattern under `exactOptionalPropertyTypes` without a cast — an absent field\n * and an explicit `undefined` both serialize away in the JSON body.\n */\nexport type SubmissionPayload = {\n formType?: FormType | string | undefined;\n name?: string | undefined;\n firstName?: string | undefined;\n lastName?: string | undefined;\n email?: string | undefined;\n phone?: string | undefined;\n message?: string | undefined;\n sourceUrl?: string | undefined;\n utm?: string | undefined;\n /** Reserved transport envelope (token/IP/UA); stripped centrally, never persisted. */\n _meta?: SubmissionMeta | undefined;\n [key: string]: unknown;\n};\n\nexport type IngestClientResult =\n | { ok: true; id: string }\n | { ok: false; status: number; error: string };\n\nexport type SubmitToIngestOptions = {\n /** Full ingest endpoint incl. the site slug, e.g. https://…/api/forms/reddoor */\n url: string;\n /** The shared FORMS_INGEST_TOKEN. */\n token: string;\n payload: SubmissionPayload;\n /** Injectable fetch (pass SvelteKit's `event.fetch`); defaults to global fetch. */\n fetch?: typeof fetch;\n};\n\n/**\n * Forward a submission to the dashboard ingest endpoint. Never throws — a network\n * failure or a non-2xx response is returned as `{ ok: false }` so the caller can\n * show a friendly error rather than a 500.\n */\nexport async function submitToIngest(opts: SubmitToIngestOptions): Promise<IngestClientResult> {\n const doFetch = opts.fetch ?? fetch;\n let res: Response;\n try {\n res = await doFetch(opts.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"x-forms-token\": opts.token },\n body: JSON.stringify(opts.payload),\n });\n } catch (err) {\n return { ok: false, status: 0, error: `network error: ${String(err)}` };\n }\n let body: unknown = null;\n try {\n body = await res.json();\n } catch {\n // non-JSON response — fall through to the error path\n }\n const obj = body && typeof body === \"object\" ? (body as Record<string, unknown>) : null;\n if (res.ok && obj && obj.ok === true) {\n return { ok: true, id: String(obj.id ?? \"\") };\n }\n const error = obj && typeof obj.error === \"string\" ? obj.error : `ingest failed (${res.status})`;\n return { ok: false, status: res.status, error };\n}\n\nexport type SubmitScreenOutOptions = {\n /** Same ingest endpoint the site already posts submissions to. */\n url: string;\n token: string;\n reason: \"honeypot\" | \"too-fast\";\n fetch?: typeof fetch;\n /** Abort budget so a slow/hung beacon can't delay the (already-successful) response. */\n timeoutMs?: number;\n};\n\n/**\n * Best-effort screen-out beacon: tells the central ingest \"a bot was screened here\"\n * (no PII) so caught-vs-delivered is observable. Never throws — a failure is returned\n * as { ok: false } and the caller ignores it (the visitor already saw success).\n */\nexport async function submitScreenOut(\n opts: SubmitScreenOutOptions,\n): Promise<{ ok: boolean; status: number }> {\n const doFetch = opts.fetch ?? fetch;\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1500);\n try {\n const res = await doFetch(opts.url, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\", \"x-forms-token\": opts.token },\n body: JSON.stringify({ screenOut: opts.reason }),\n signal: controller.signal,\n });\n return { ok: res.ok, status: res.status };\n } catch {\n return { ok: false, status: 0 };\n } finally {\n clearTimeout(timer);\n }\n}\n\nexport type ScreenInput = { botField?: string | null; elapsedMs?: number | null };\nexport type ScreenResult = { ok: true } | { ok: false; reason: \"honeypot\" | \"too-fast\" };\n\n/**\n * Minimum plausible fill time; faster than this reads as a bot. Kept low (800ms)\n * on purpose: a too-fast fill is dropped *silently* (the visitor sees success),\n * so a real human who happens to be quick — autofill, a short form, a returning\n * visitor — would lose their lead with no trace. Below this, a submit is\n * effectively instant (page render → fill → click → network all under ~0.8s),\n * which a human realistically never beats but a script does. The honeypot is the\n * primary bot signal; this is the secondary one, so it errs toward letting\n * borderline-fast humans through.\n */\nexport const MIN_FILL_MS = 800;\n\n/**\n * Cheap bot screen for the site action. A filled honeypot is a bot; a submission\n * faster than MIN_FILL_MS is a bot. Missing timing data (null/undefined) is NOT a\n * rejection — a prerendered/cached page can't plant a fresh timestamp, and the\n * honeypot remains the primary signal.\n *\n * Any numeric elapsed BELOW the floor — including a NEGATIVE value — is too-fast. A\n * negative elapsed time is impossible for a real fill: it means the client posted a\n * FUTURE timestamp. The earlier `>= 0` guard let that slip through (negative skipped\n * the `< MIN_FILL_MS` branch and returned ok), silently bypassing the timing gate.\n */\nexport function screenSubmission(input: ScreenInput): ScreenResult {\n if (typeof input.botField === \"string\" && input.botField.trim().length > 0) {\n return { ok: false, reason: \"honeypot\" };\n }\n if (typeof input.elapsedMs === \"number\" && input.elapsedMs < MIN_FILL_MS) {\n return { ok: false, reason: \"too-fast\" };\n }\n return { ok: true };\n}\n\nexport { SUBMISSION_FORM_TYPES, type FormType };\n","import { fail, redirect, type ActionFailure, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\nimport { buildSubmissionMeta } from \"./meta.js\";\n\n/** Endpoint + token for the dashboard ingest, read per-request from site env. */\nexport type IngestActionConfig = { url?: string; token?: string };\n\nexport type CreateIngestActionOptions = {\n /** Stamped onto every payload as `formType` (a SUBMISSION_FORM_TYPES value). */\n formType: string;\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map this form's fields to a payload. The factory's `formType` is always\n * authoritative and cannot be overridden by `buildPayload`.\n */\n buildPayload: (form: FormData, event: RequestEvent) => SubmissionPayload;\n /** Honeypot input name. Default \"bot-field\". */\n botFieldName?: string;\n /** Hidden timestamp input name (planted in `load`). Default \"ts\". */\n tsFieldName?: string;\n /** Field carrying the Cloudflare Turnstile token. Default \"cf-turnstile-response\". */\n turnstileFieldName?: string;\n /** fail(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** fail(502) copy when the ingest endpoint rejects/errors. */\n errorMessage?: string;\n /** Injectable clock for tests. Default Date.now. */\n now?: () => number;\n /** If set, a successful OR bot-screened submission throws redirect(303, redirectTo)\n * instead of returning {success:true} (e.g. a dedicated /thank-you page). */\n redirectTo?: string;\n};\n\nexport type IngestActionData = { success: true } | ActionFailure<{ error: string }>;\n\n/**\n * Build a SvelteKit `default` form action that screens for bots, forwards the\n * submission to the dashboard ingest endpoint, and returns SvelteKit-shaped\n * results. The per-form field mapping is the only thing a site must supply.\n */\nexport function createIngestAction(\n opts: CreateIngestActionOptions,\n): (event: RequestEvent) => Promise<IngestActionData> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const tsFieldName = opts.tsFieldName ?? \"ts\";\n const turnstileFieldName = opts.turnstileFieldName ?? \"cf-turnstile-response\";\n const now = opts.now ?? Date.now;\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let form: FormData;\n try {\n form = await event.request.formData();\n } catch {\n console.error(`[forms-ingest] ${opts.formType}: could not parse form body`);\n return fail(400, { error: failed });\n }\n\n // Bot screen: a filled honeypot OR an implausibly fast fill is silently\n // accepted (return success, do NOT forward) so bots get no signal.\n const screen = screenSubmission({\n botField: form.get(botFieldName)?.toString() ?? null,\n elapsedMs: elapsedMs(form.get(tsFieldName), now),\n });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // succeed exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return succeed();\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${opts.formType}`);\n return fail(500, { error: unavailable });\n }\n\n const result = await submitToIngest({\n url,\n token,\n fetch: event.fetch,\n payload: {\n ...opts.buildPayload(form, event),\n formType: opts.formType,\n _meta: buildSubmissionMeta(event, form.get(turnstileFieldName)?.toString()),\n },\n });\n if (!result.ok) {\n console.error(`[forms-ingest] ${opts.formType} → ${result.status}: ${result.error}`);\n return fail(502, { error: failed });\n }\n return succeed();\n };\n\n // Single success path: redirect when configured (e.g. a dedicated /thank-you\n // page), otherwise return the SvelteKit-shaped success. `redirect()` throws, so\n // the trailing `return` keeps the `{ success: true }` type.\n function succeed(): { success: true } {\n if (opts.redirectTo) redirect(303, opts.redirectTo);\n return { success: true };\n }\n}\n\n// `FormDataEntryValue` is a DOM-lib global; this package compiles with only the\n// ES2022 lib + @types/node, where it is not in scope. Derive the type from the\n// in-scope `FormData.get` return instead — same value, no DOM-lib dependency.\nfunction elapsedMs(tsRaw: ReturnType<FormData[\"get\"]>, now: () => number): number | null {\n const ts = Number(tsRaw);\n if (!Number.isFinite(ts) || ts <= 0) return null;\n // Clamp at 0: elapsed time can't be negative. A future `ts` (clock skew or a bot\n // forging one) would otherwise yield a negative value; clamping makes it read as\n // 0ms (effectively instant) so the MIN_FILL_MS gate still trips. Defense-in-depth\n // with screenSubmission, which also treats any sub-floor elapsed as too-fast.\n return Math.max(0, now() - ts);\n}\n","/** The reserved wire envelope a fleet site forwards alongside the lead fields. */\nexport type SubmissionMeta = {\n turnstileToken?: string;\n clientIp?: string;\n userAgent?: string;\n};\n\nfunction str(v: unknown): string {\n return typeof v === \"string\" ? v.trim() : \"\";\n}\n\n/**\n * Defensively read the reserved `_meta` envelope off an untrusted ingest payload\n * (CENTRAL side, used by the ingest handler). Keeps only non-blank string fields,\n * dropping the rest, so a bot cannot smuggle a non-string clientIp/userAgent into\n * the transient scoring path. The token/IP/UA read here are used transiently\n * (Turnstile `remoteip` + scoring) and are NEVER persisted; the token is never\n * stored. The SITE-side writer (`buildSubmissionMeta`) is added by the\n * site-factory task; `readMeta` is the single reader (there is no `parseMeta`).\n */\nexport function readMeta(payload: unknown): SubmissionMeta {\n const meta: SubmissionMeta = {};\n if (typeof payload !== \"object\" || payload === null) return meta;\n const raw = (payload as Record<string, unknown>)._meta;\n if (typeof raw !== \"object\" || raw === null) return meta;\n const m = raw as Record<string, unknown>;\n const token = str(m.turnstileToken);\n if (token) meta.turnstileToken = token;\n const ip = str(m.clientIp);\n if (ip) meta.clientIp = ip;\n const ua = str(m.userAgent);\n if (ua) meta.userAgent = ua;\n return meta;\n}\n\n/**\n * SITE-side event shape `buildSubmissionMeta` reads. Structural (not SvelteKit's\n * `RequestEvent`) so this leaf stays SDK-free; a real `RequestEvent` is\n * structurally assignable (`getClientAddress: () => string`, `request.headers`\n * is a `Headers` with a `get`).\n */\ntype MetaEvent = {\n getClientAddress?: () => string;\n request?: { headers?: { get?: (name: string) => string | null } };\n};\n\n/**\n * Build the transient `_meta` envelope a site forwards to central ingest:\n * `{ turnstileToken?, clientIp?, userAgent? }`. Returns `undefined` when no\n * field yields a value so callers can attach it unconditionally without\n * polluting the payload (an `undefined` value is dropped by `JSON.stringify`).\n * `getClientAddress` is guarded (some adapters lack a client address and can\n * throw); UA is read defensively. None of this is ever persisted.\n */\nexport function buildSubmissionMeta(\n event: MetaEvent,\n turnstileToken: string | null | undefined,\n): SubmissionMeta | undefined {\n const meta: SubmissionMeta = {};\n\n const token = typeof turnstileToken === \"string\" ? turnstileToken.trim() : \"\";\n if (token) meta.turnstileToken = token;\n\n if (typeof event.getClientAddress === \"function\") {\n try {\n const ip = event.getClientAddress();\n if (typeof ip === \"string\" && ip.trim()) meta.clientIp = ip.trim();\n } catch {\n // Some adapters have no client address and throw; drop clientIp silently.\n }\n }\n\n const ua = event.request?.headers?.get?.(\"user-agent\");\n if (typeof ua === \"string\" && ua.trim()) meta.userAgent = ua.trim();\n\n return Object.keys(meta).length > 0 ? meta : undefined;\n}\n","import { json, type RequestEvent } from \"@sveltejs/kit\";\nimport {\n submitToIngest,\n screenSubmission,\n submitScreenOut,\n type SubmissionPayload,\n} from \"./client.js\";\nimport { SUBMISSION_FORM_TYPES, type FormType } from \"./types.js\";\nimport type { IngestActionConfig } from \"./action.js\";\nimport { buildSubmissionMeta } from \"./meta.js\";\n\n/**\n * Options for {@link createIngestEndpoint} — the JSON sibling of\n * `createIngestAction` for client-driven forms (modals / lightboxes / fetch)\n * that POST JSON to a `+server.ts` route instead of using a form action.\n */\nexport type CreateIngestEndpointOptions = {\n /** Read at call time so SvelteKit's dynamic private env resolves per-request. */\n getConfig: () => IngestActionConfig;\n /**\n * Map the parsed JSON body to a payload. Must set `formType` UNLESS the fixed\n * `formType` option is provided (then that is authoritative and overrides it).\n */\n buildPayload: (body: Record<string, unknown>, event: RequestEvent) => SubmissionPayload;\n /** Fixed formType for single-type endpoints; omit for multi-type endpoints\n * where `buildPayload` derives formType from the body. */\n formType?: string;\n /** Honeypot field name in the JSON body. Default \"bot-field\". */\n botFieldName?: string;\n /** Field carrying the Cloudflare Turnstile token. Default \"cf-turnstile-response\". */\n turnstileFieldName?: string;\n /** json(500) copy when env vars are unset. */\n unavailableMessage?: string;\n /** json(400/502) copy for bad input / ingest failure. */\n errorMessage?: string;\n};\n\nfunction isFormType(v: unknown): v is FormType {\n return typeof v === \"string\" && (SUBMISSION_FORM_TYPES as readonly string[]).includes(v);\n}\n\nfunction str(v: unknown): string | undefined {\n return typeof v === \"string\" ? v : undefined;\n}\n\n/**\n * Build a JSON `POST` handler that screens for bots, forwards the submission to\n * the dashboard ingest endpoint, and returns `{ ok }`-shaped JSON. The per-form\n * field mapping (`buildPayload`) is the only thing a site must supply. The\n * returned function is structurally a SvelteKit `RequestHandler`.\n */\nexport function createIngestEndpoint(\n opts: CreateIngestEndpointOptions,\n): (event: RequestEvent) => Promise<Response> {\n const botFieldName = opts.botFieldName ?? \"bot-field\";\n const turnstileFieldName = opts.turnstileFieldName ?? \"cf-turnstile-response\";\n const unavailable =\n opts.unavailableMessage ?? \"This form is temporarily unavailable. Please email us directly.\";\n const failed =\n opts.errorMessage ?? \"Something went wrong sending your message. Please try again.\";\n\n return async (event) => {\n let body: Record<string, unknown>;\n try {\n const parsed: unknown = await event.request.json();\n if (!parsed || typeof parsed !== \"object\") throw new Error(\"body is not an object\");\n body = parsed as Record<string, unknown>;\n } catch {\n console.error(\"[forms-ingest] could not parse JSON body\");\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n // Bot screen: honeypot only. A client POST carries no server-planted ts, and\n // screenSubmission treats a missing elapsedMs as OK. A filled honeypot is\n // silently accepted (return ok, do NOT forward) so bots get no signal.\n const screen = screenSubmission({ botField: str(body[botFieldName]) ?? null });\n if (!screen.ok) {\n // Best-effort screen-out beacon (no PII) so catch-rate is observable, then\n // return success exactly as before — the bot/visitor still sees success.\n const cfg = opts.getConfig();\n if (cfg.url && cfg.token) {\n await submitScreenOut({\n url: cfg.url,\n token: cfg.token,\n reason: screen.reason,\n fetch: event.fetch,\n });\n }\n return json({ ok: true });\n }\n\n // buildPayload runs on untrusted JSON; a careless field access (e.g.\n // `body.name.trim()` on a non-string) would otherwise escape as a 500. Treat\n // a throw as a malformed request (400), keeping the \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...opts.buildPayload(body, event),\n ...(opts.formType ? { formType: opts.formType } : {}),\n _meta: buildSubmissionMeta(event, str(body[turnstileFieldName])),\n };\n } catch (err) {\n console.error(`[forms-ingest] buildPayload threw: ${String(err)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n if (!isFormType(payload.formType)) {\n console.error(`[forms-ingest] invalid formType: ${String(payload.formType)}`);\n return json({ ok: false, error: failed }, { status: 400 });\n }\n\n const { url, token } = opts.getConfig();\n if (!url || !token) {\n console.error(`[forms-ingest] config missing for formType=${payload.formType}`);\n return json({ ok: false, error: unavailable }, { status: 500 });\n }\n\n const result = await submitToIngest({ url, token, fetch: event.fetch, payload });\n if (!result.ok) {\n console.error(`[forms-ingest] ${payload.formType} → ${result.status}: ${result.error}`);\n return json({ ok: false, error: failed }, { status: 502 });\n }\n return json({ ok: true });\n };\n}\n"],"mappings":"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","names":[]}
@@ -3,17 +3,17 @@ import {
3
3
  } from "./chunk-PZQ3WED2.js";
4
4
  import {
5
5
  formatFleetWriteSummary
6
- } from "./chunk-JD3X3QMP.js";
7
- import "./chunk-YBYWNUT3.js";
6
+ } from "./chunk-KN5OC5YH.js";
7
+ import "./chunk-T34EUM5B.js";
8
8
  import {
9
9
  detectSignalEvents,
10
10
  fleetSweptEvent
11
11
  } from "./chunk-5L3FPQIJ.js";
12
12
  import {
13
13
  recordFleetEventsBestEffort
14
- } from "./chunk-G2YRXV26.js";
15
- import "./chunk-PTPTVVY7.js";
16
- import "./chunk-XKKTK6WA.js";
14
+ } from "./chunk-OTBA7DON.js";
15
+ import "./chunk-PH3AGF4Z.js";
16
+ import "./chunk-HIN3P4ZN.js";
17
17
  import {
18
18
  makeGitHub
19
19
  } from "./chunk-YEGHUODU.js";
@@ -22,7 +22,7 @@ import {
22
22
  listWebsites,
23
23
  siteSlug,
24
24
  updateGitHubSignals
25
- } from "./chunk-4P6UQG5L.js";
25
+ } from "./chunk-CL5DYUWQ.js";
26
26
  import {
27
27
  openBase,
28
28
  readAirtableConfig
@@ -134,4 +134,4 @@ export {
134
134
  githubSignalsExitCode,
135
135
  runGitHubSignalsCommand
136
136
  };
137
- //# sourceMappingURL=github-signals-3O64PFWP.js.map
137
+ //# sourceMappingURL=github-signals-VWBMQNLH.js.map
package/dist/index.d.ts CHANGED
@@ -432,6 +432,10 @@ type WebsiteRow = {
432
432
  * blank → skipped. The API key is `key-dc` format; dc is derived from it. */
433
433
  mailchimpApiKey: string | null;
434
434
  mailchimpAudienceId: string | null;
435
+ /** Per-site Cloudflare Turnstile gate. When true, a submission whose Turnstile
436
+ * token verifies as "fail" is escalated to auto-spam regardless of content score
437
+ * (an absent/unverifiable token stays neutral — fail-open). Airtable checkbox. */
438
+ requireTurnstile: boolean;
435
439
  /** GitHub-signals sweep (slice 2a), written nightly by `github-signals --fleet`. */
436
440
  renovateFailingCis: number | null;
437
441
  defaultBranchCi: string | null;
@@ -752,7 +756,7 @@ declare function selfPackageVersion(callerImportMetaUrl: string): string;
752
756
  /** Caret-pinned range against this package's own version: e.g. "^0.6.2". */
753
757
  declare function selfCaretRange(callerImportMetaUrl: string): string;
754
758
 
755
- declare const SUBMISSION_STATUSES: readonly ["new", "read", "archived", "spam"];
759
+ declare const SUBMISSION_STATUSES: readonly ["new", "read", "archived", "spam", "spam_auto"];
756
760
  type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
757
761
  declare const NOTIFY_STATUSES: readonly ["sent", "failed", "skipped"];
758
762
  type NotifyStatus = (typeof NOTIFY_STATUSES)[number];
@@ -773,6 +777,10 @@ type SubmissionRow = {
773
777
  status: SubmissionStatus;
774
778
  notifyStatus: NotifyStatus;
775
779
  resendMessageId: string | null;
780
+ /** Heuristic spam score at ingest time; null for pre-classifier / un-scored rows. */
781
+ spamScore?: number | null;
782
+ /** Comma-joined classifier reason codes (e.g. "links:3,disposable-email"); null when unscored. */
783
+ spamReason?: string | null;
776
784
  };
777
785
 
778
786
  type ScreenOutTotals = {
@@ -892,6 +900,9 @@ type CockpitModel = {
892
900
  } | null;
893
901
  /** Recent fleet-activity events for the "Recently" lane (optional for back-compat). */
894
902
  recent?: RecentEntry[];
903
+ /** Fleet-wide count of submissions auto-filtered as spam in the affordance window
904
+ * (optional for back-compat). Drives the cockpit "N auto-filtered this week" line. */
905
+ autoFiltered?: number;
895
906
  };
896
907
 
897
908
  /**
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import "./chunk-CH6K2VMM.js";
1
+ import "./chunk-JDGIO54P.js";
2
2
  import {
3
3
  findDueReports
4
4
  } from "./chunk-VKAYZVMI.js";
@@ -24,11 +24,11 @@ import {
24
24
  } from "./chunk-ZYBOT5PM.js";
25
25
  import {
26
26
  draftReportForSite
27
- } from "./chunk-G35B5U6F.js";
27
+ } from "./chunk-ULL4AD3Y.js";
28
28
  import "./chunk-6LIWDIXU.js";
29
29
  import {
30
30
  sendApprovedReports
31
- } from "./chunk-CWSQZ27M.js";
31
+ } from "./chunk-YKOOAWVL.js";
32
32
  import "./chunk-OGGRFBL4.js";
33
33
  import "./chunk-GECESMVS.js";
34
34
  import {
@@ -64,9 +64,9 @@ import {
64
64
  securityAudit
65
65
  } from "./chunk-WCN54744.js";
66
66
  import "./chunk-LKGVSM2O.js";
67
- import "./chunk-G2YRXV26.js";
68
- import "./chunk-PTPTVVY7.js";
69
- import "./chunk-XKKTK6WA.js";
67
+ import "./chunk-OTBA7DON.js";
68
+ import "./chunk-PH3AGF4Z.js";
69
+ import "./chunk-HIN3P4ZN.js";
70
70
  import "./chunk-GASBX52O.js";
71
71
  import "./chunk-YEGHUODU.js";
72
72
  import {
@@ -78,12 +78,12 @@ import "./chunk-XXTZBPUY.js";
78
78
  import "./chunk-XTK5VIQB.js";
79
79
  import {
80
80
  fromAirtableBase
81
- } from "./chunk-NMGGP5WN.js";
81
+ } from "./chunk-Y4O44LZB.js";
82
82
  import "./chunk-U5SZ4FOP.js";
83
83
  import {
84
84
  SEVERITY_RANK,
85
85
  siteSlug
86
- } from "./chunk-4P6UQG5L.js";
86
+ } from "./chunk-CL5DYUWQ.js";
87
87
  import "./chunk-HN6I2XWI.js";
88
88
  import "./chunk-WEKL2HYJ.js";
89
89
  import "./chunk-6VAL7XJT.js";
@@ -172,6 +172,9 @@ function extraFieldsList(raw) {
172
172
  ).join("");
173
173
  return rows;
174
174
  }
175
+ function isVisibleInStrip(s) {
176
+ return s.status !== "spam_auto";
177
+ }
175
178
  function renderSubmissionRow(s) {
176
179
  const when = s.submittedAt ? escapeHtml(relativeTimeFromNow(s.submittedAt)) : "\u2014";
177
180
  const type = escapeHtml(s.formType);
@@ -181,6 +184,8 @@ function renderSubmissionRow(s) {
181
184
  const id = escapeHtml(s.id);
182
185
  const url = `/api/submissions/${encodeURIComponent(s.id)}/status`;
183
186
  const btn = (label, action) => `<button class="subm-status" data-id="${id}" data-status="${action}" data-url="${url}">${label}</button>`;
187
+ const provenance = s.status === "spam_auto" && s.spamScore !== null && s.spamScore !== void 0 ? ` <span class="subm-provenance" title="${escapeHtml(s.spamReason ?? "")}">auto-spam \xB7 ${escapeHtml(String(s.spamScore))}</span>` : "";
188
+ const recover = s.status === "spam_auto" ? btn("Not spam \u2192 new", "new") : "";
184
189
  const kv = (label, value) => value === null || value === "" ? "" : `<div class="subm-kv"><span class="k">${label}</span> ${escapeHtml(String(value))}</div>`;
185
190
  const sourceLink = s.sourceUrl ? `<div class="subm-kv"><span class="k">Source</span> <a href="${escapeHtml(safeUrl(s.sourceUrl))}" rel="noopener noreferrer">${escapeHtml(s.sourceUrl)}</a></div>` : "";
186
191
  const messageBlock = s.message ? `<div class="subm-kv"><span class="k">Message</span></div><div class="subm-msg">${escapeHtml(s.message)}</div>` : "";
@@ -196,10 +201,10 @@ function renderSubmissionRow(s) {
196
201
  ].join("");
197
202
  return `<li class="subm-item">
198
203
  <details>
199
- <summary class="subm-head"><strong>${type}</strong> \xB7 ${who} <span class="muted">${email}</span> <span class="pill subm-${status}">${status}</span> <span class="muted">${when}</span></summary>
204
+ <summary class="subm-head"><strong>${type}</strong> \xB7 ${who} <span class="muted">${email}</span> <span class="pill subm-${status}">${status}</span>${provenance} <span class="muted">${when}</span></summary>
200
205
  <div class="subm-detail">${details}</div>
201
206
  </details>
202
- <div class="subm-actions">${btn("Read", "read")}${btn("Archive", "archived")}${btn("Spam", "spam")}</div>
207
+ <div class="subm-actions">${recover}${btn("Read", "read")}${btn("Archive", "archived")}${btn("Spam", "spam")}</div>
203
208
  </li>`;
204
209
  }
205
210
  var SUBMISSION_STYLES = `.subm-list { list-style: none; padding: 0; margin: 0; }
@@ -220,6 +225,8 @@ button.subm-status:disabled { opacity: 0.6; cursor: default; }
220
225
  .pill.subm-read { background: #f0f0f0; color: #555; }
221
226
  .pill.subm-archived { background: #eee; color: #888; }
222
227
  .pill.subm-spam { background: #fdecea; color: #b00; }
228
+ .pill.subm-spam_auto { background: #fff4e5; color: #a65a00; }
229
+ .subm-provenance { font-size: 0.72rem; border-radius: 0.25rem; padding: 0 0.35rem; white-space: nowrap; background: #fff4e5; color: #a65a00; }
223
230
  .subm-viewall { font-size: 0.8rem; font-weight: normal; margin-left: 0.4rem; white-space: nowrap; }`;
224
231
  var SUBMISSION_STATUS_SCRIPT = `document.querySelectorAll("button.subm-status").forEach((b) => {
225
232
  b.addEventListener("click", async () => {
@@ -363,12 +370,13 @@ function reportRow(r) {
363
370
  }
364
371
  var SUBMISSIONS_PER_SITE_CAP = 25;
365
372
  function submissionsSection(submissions, site) {
366
- if (submissions.length === 0) return "";
367
- const recent = [...submissions].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_PER_SITE_CAP);
368
- const note = submissions.length > recent.length ? `<span class="muted"> \u2014 showing ${recent.length} of ${submissions.length}</span>` : "";
373
+ const visible = submissions.filter(isVisibleInStrip);
374
+ if (visible.length === 0) return "";
375
+ const recent = [...visible].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_PER_SITE_CAP);
376
+ const note = visible.length > recent.length ? `<span class="muted"> \u2014 showing ${recent.length} of ${visible.length}</span>` : "";
369
377
  const viewAll = `<a class="subm-viewall" href="/submissions?site=${escapeHtml(siteSlug(site.name))}">View all for this site \u2192</a>`;
370
378
  return `<div class="section submissions">
371
- <h2>Form submissions (${submissions.length})${note} ${viewAll}</h2>
379
+ <h2>Form submissions (${visible.length})${note} ${viewAll}</h2>
372
380
  <ul class="subm-list">${recent.map(renderSubmissionRow).join("")}</ul>
373
381
  </div>`;
374
382
  }
@@ -1103,7 +1111,9 @@ function renderInboxLane(model) {
1103
1111
  const subs = model.submissions ?? [];
1104
1112
  const spam = model.spam;
1105
1113
  const hasSpam = !!spam && (spam.caught > 0 || spam.through > 0);
1106
- if (subs.length === 0 && !hasSpam) return "";
1114
+ const autoFiltered = model.autoFiltered ?? 0;
1115
+ const hasAutoFiltered = autoFiltered > 0;
1116
+ if (subs.length === 0 && !hasSpam && !hasAutoFiltered) return "";
1107
1117
  const shown = [...subs].sort((a, b) => (b.submittedAt ?? "").localeCompare(a.submittedAt ?? "")).slice(0, SUBMISSIONS_STRIP_CAP);
1108
1118
  const rows = shown.map((sub) => {
1109
1119
  const href = `/s/${escapeHtml(sub.slug)}`;
@@ -1120,10 +1130,12 @@ function renderInboxLane(model) {
1120
1130
  const more = overflow > 0 ? `<div class="approve-row subm-more muted"><a href="/submissions">+${overflow} more \u2014 view all submissions</a></div>` : `<div class="approve-row subm-more muted"><a href="/submissions">View all submissions \u2192</a></div>`;
1121
1131
  const spamLine = hasSpam ? `<div class="spam-rollup muted">\u{1F6E1} Spam (30d) \u2014 caught ${spam.caught} \xB7 through ${spam.through}</div>` : "";
1122
1132
  const spamInSummary = hasSpam ? " \xB7 \u{1F6E1} Spam (30d)" : "";
1133
+ const autoFilteredLine = hasAutoFiltered ? `<div class="spam-rollup muted">\u{1F6AB} ${autoFiltered} auto-filtered this week \u2014 <a href="/submissions?status=spam_auto">review</a></div>` : "";
1123
1134
  return `<details class="inbox">
1124
1135
  <summary>\u{1F4E5} Submissions (${subs.length} new)${spamInSummary}</summary>
1125
1136
  ${rows}${subs.length > 0 ? more : ""}
1126
1137
  ${spamLine}
1138
+ ${autoFilteredLine}
1127
1139
  </details>`;
1128
1140
  }
1129
1141
  var AUDIT_SCRIPT = `<script>