@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.
- package/dist/airtable-33SY2UBZ.js +9 -0
- package/dist/{announce-YPXVHNNP.js → announce-74ZIAV26.js} +4 -4
- package/dist/{bump-deps-INDDTDSG.js → bump-deps-HYBYNPMY.js} +2 -2
- package/dist/{chunk-LISJ3K3R.js → chunk-7MJJEEII.js} +2 -2
- package/dist/{chunk-4P6UQG5L.js → chunk-CL5DYUWQ.js} +4 -1
- package/dist/chunk-CL5DYUWQ.js.map +1 -0
- package/dist/{chunk-XKKTK6WA.js → chunk-HIN3P4ZN.js} +23 -2
- package/dist/chunk-HIN3P4ZN.js.map +1 -0
- package/dist/{chunk-CH6K2VMM.js → chunk-JDGIO54P.js} +2 -2
- package/dist/{chunk-JD3X3QMP.js → chunk-KN5OC5YH.js} +3 -3
- package/dist/{chunk-XKSKHHSX.js → chunk-OAMJINM5.js} +3 -3
- package/dist/{chunk-G2YRXV26.js → chunk-OTBA7DON.js} +2 -2
- package/dist/{chunk-PTPTVVY7.js → chunk-PH3AGF4Z.js} +2 -2
- package/dist/{chunk-YBYWNUT3.js → chunk-T34EUM5B.js} +2 -2
- package/dist/{chunk-G35B5U6F.js → chunk-ULL4AD3Y.js} +2 -2
- package/dist/{chunk-NMGGP5WN.js → chunk-Y4O44LZB.js} +2 -2
- package/dist/{chunk-CWSQZ27M.js → chunk-YKOOAWVL.js} +3 -3
- package/dist/cli/bin.js +15 -15
- package/dist/cli/commands/audit.js +8 -8
- package/dist/client-NVVQK3HH.js +11 -0
- package/dist/{convert-to-pnpm-4GD4667L.js → convert-to-pnpm-5MPZWFHM.js} +2 -2
- package/dist/{db-YBLQR2YY.js → db-3QW2XEXA.js} +3 -3
- package/dist/{digest-SIZUXT67.js → digest-MECMZPFE.js} +3 -3
- package/dist/forms/index.d.ts +13 -0
- package/dist/forms/index.js +28 -2
- package/dist/forms/index.js.map +1 -1
- package/dist/{github-signals-3O64PFWP.js → github-signals-VWBMQNLH.js} +7 -7
- package/dist/index.d.ts +12 -1
- package/dist/index.js +27 -15
- package/dist/index.js.map +1 -1
- package/dist/{init-KMWXJZEX.js → init-X3BCJAUG.js} +2 -2
- package/dist/{launch-5KRCKFYM.js → launch-NQCL5K5H.js} +5 -5
- package/dist/{lighthouse-airtable-5JICCZND.js → lighthouse-airtable-QUNOSDT5.js} +3 -3
- package/dist/migrate-35DNNF3H.js +7 -0
- package/dist/{onboard-RCBXS5B3.js → onboard-7JOBZY35.js} +2 -2
- package/dist/{orchestrate-O4KR6JME.js → orchestrate-JQX72RJE.js} +6 -6
- package/dist/{renovate-dispatch-ABRSIT77.js → renovate-dispatch-PWXFZNHN.js} +2 -2
- package/dist/{report-LSGTMXQJ.js → report-QUKNKUUE.js} +5 -5
- package/dist/{self-updating-CPMY3HFY.js → self-updating-OKJKU2FE.js} +2 -2
- package/dist/{selftest-7Y5K4PYL.js → selftest-6JVYR24F.js} +8 -8
- package/dist/{svelte-codemods-GJRTBLJE.js → svelte-codemods-GG6EEES5.js} +2 -2
- package/dist/{sync-configs-NYJNSIRN.js → sync-configs-UFHZZPL7.js} +2 -2
- package/dist/{upgrade-LVYRDKDL.js → upgrade-S25N3473.js} +2 -2
- package/dist/{websites-5ETIXB7F.js → websites-U3UZS5VG.js} +2 -2
- package/dist/{write-audits-to-airtable-GHR337N7.js → write-audits-to-airtable-GAJUCSTY.js} +4 -4
- package/package.json +1 -1
- package/dist/airtable-M4AQHRNV.js +0 -9
- package/dist/chunk-4P6UQG5L.js.map +0 -1
- package/dist/chunk-XKKTK6WA.js.map +0 -1
- package/dist/client-C2GOZD2D.js +0 -11
- package/dist/migrate-BFOG2OCP.js +0 -7
- /package/dist/{airtable-M4AQHRNV.js.map → airtable-33SY2UBZ.js.map} +0 -0
- /package/dist/{announce-YPXVHNNP.js.map → announce-74ZIAV26.js.map} +0 -0
- /package/dist/{bump-deps-INDDTDSG.js.map → bump-deps-HYBYNPMY.js.map} +0 -0
- /package/dist/{chunk-LISJ3K3R.js.map → chunk-7MJJEEII.js.map} +0 -0
- /package/dist/{chunk-CH6K2VMM.js.map → chunk-JDGIO54P.js.map} +0 -0
- /package/dist/{chunk-JD3X3QMP.js.map → chunk-KN5OC5YH.js.map} +0 -0
- /package/dist/{chunk-XKSKHHSX.js.map → chunk-OAMJINM5.js.map} +0 -0
- /package/dist/{chunk-G2YRXV26.js.map → chunk-OTBA7DON.js.map} +0 -0
- /package/dist/{chunk-PTPTVVY7.js.map → chunk-PH3AGF4Z.js.map} +0 -0
- /package/dist/{chunk-YBYWNUT3.js.map → chunk-T34EUM5B.js.map} +0 -0
- /package/dist/{chunk-G35B5U6F.js.map → chunk-ULL4AD3Y.js.map} +0 -0
- /package/dist/{chunk-NMGGP5WN.js.map → chunk-Y4O44LZB.js.map} +0 -0
- /package/dist/{chunk-CWSQZ27M.js.map → chunk-YKOOAWVL.js.map} +0 -0
- /package/dist/{client-C2GOZD2D.js.map → client-NVVQK3HH.js.map} +0 -0
- /package/dist/{convert-to-pnpm-4GD4667L.js.map → convert-to-pnpm-5MPZWFHM.js.map} +0 -0
- /package/dist/{db-YBLQR2YY.js.map → db-3QW2XEXA.js.map} +0 -0
- /package/dist/{digest-SIZUXT67.js.map → digest-MECMZPFE.js.map} +0 -0
- /package/dist/{github-signals-3O64PFWP.js.map → github-signals-VWBMQNLH.js.map} +0 -0
- /package/dist/{init-KMWXJZEX.js.map → init-X3BCJAUG.js.map} +0 -0
- /package/dist/{launch-5KRCKFYM.js.map → launch-NQCL5K5H.js.map} +0 -0
- /package/dist/{lighthouse-airtable-5JICCZND.js.map → lighthouse-airtable-QUNOSDT5.js.map} +0 -0
- /package/dist/{migrate-BFOG2OCP.js.map → migrate-35DNNF3H.js.map} +0 -0
- /package/dist/{onboard-RCBXS5B3.js.map → onboard-7JOBZY35.js.map} +0 -0
- /package/dist/{orchestrate-O4KR6JME.js.map → orchestrate-JQX72RJE.js.map} +0 -0
- /package/dist/{renovate-dispatch-ABRSIT77.js.map → renovate-dispatch-PWXFZNHN.js.map} +0 -0
- /package/dist/{report-LSGTMXQJ.js.map → report-QUKNKUUE.js.map} +0 -0
- /package/dist/{self-updating-CPMY3HFY.js.map → self-updating-OKJKU2FE.js.map} +0 -0
- /package/dist/{selftest-7Y5K4PYL.js.map → selftest-6JVYR24F.js.map} +0 -0
- /package/dist/{svelte-codemods-GJRTBLJE.js.map → svelte-codemods-GG6EEES5.js.map} +0 -0
- /package/dist/{sync-configs-NYJNSIRN.js.map → sync-configs-UFHZZPL7.js.map} +0 -0
- /package/dist/{upgrade-LVYRDKDL.js.map → upgrade-S25N3473.js.map} +0 -0
- /package/dist/{websites-5ETIXB7F.js.map → websites-U3UZS5VG.js.map} +0 -0
- /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-
|
|
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-
|
|
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-
|
|
229
|
+
//# sourceMappingURL=digest-MECMZPFE.js.map
|
package/dist/forms/index.d.ts
CHANGED
|
@@ -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. */
|
package/dist/forms/index.js
CHANGED
|
@@ -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: {
|
|
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)}`);
|
package/dist/forms/index.js.map
CHANGED
|
@@ -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":";;;;;AA4CA,eAAsB,eAAe,MAA0D;AAC7F,QAAM,UAAU,KAAK,SAAS;AAC9B,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,QAAQ,KAAK,KAAK;AAAA,MAC5B,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,oBAAoB,iBAAiB,KAAK,MAAM;AAAA,MAC3E,MAAM,KAAK,UAAU,KAAK,OAAO;AAAA,IACnC,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,EAAE,IAAI,OAAO,QAAQ,GAAG,OAAO,kBAAkB,OAAO,GAAG,CAAC,GAAG;AAAA,EACxE;AACA,MAAI,OAAgB;AACpB,MAAI;AACF,WAAO,MAAM,IAAI,KAAK;AAAA,EACxB,QAAQ;AAAA,EAER;AACA,QAAM,MAAM,QAAQ,OAAO,SAAS,WAAY,OAAmC;AACnF,MAAI,IAAI,MAAM,OAAO,IAAI,OAAO,MAAM;AACpC,WAAO,EAAE,IAAI,MAAM,IAAI,OAAO,IAAI,MAAM,EAAE,EAAE;AAAA,EAC9C;AACA,QAAM,QAAQ,OAAO,OAAO,IAAI,UAAU,WAAW,IAAI,QAAQ,kBAAkB,IAAI,MAAM;AAC7F,SAAO,EAAE,IAAI,OAAO,QAAQ,IAAI,QAAQ,MAAM;AAChD;AAiBA,eAAsB,gBACpB,MAC0C;AAC1C,QAAM,UAAU,KAAK,SAAS;AAC9B,QAAM,aAAa,IAAI,gBAAgB;AACvC,QAAM,QAAQ,WAAW,MAAM,WAAW,MAAM,GAAG,KAAK,aAAa,IAAI;AACzE,MAAI;AACF,UAAM,MAAM,MAAM,QAAQ,KAAK,KAAK;AAAA,MAClC,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,oBAAoB,iBAAiB,KAAK,MAAM;AAAA,MAC3E,MAAM,KAAK,UAAU,EAAE,WAAW,KAAK,OAAO,CAAC;AAAA,MAC/C,QAAQ,WAAW;AAAA,IACrB,CAAC;AACD,WAAO,EAAE,IAAI,IAAI,IAAI,QAAQ,IAAI,OAAO;AAAA,EAC1C,QAAQ;AACN,WAAO,EAAE,IAAI,OAAO,QAAQ,EAAE;AAAA,EAChC,UAAE;AACA,iBAAa,KAAK;AAAA,EACpB;AACF;AAeO,IAAM,cAAc;AAapB,SAAS,iBAAiB,OAAkC;AACjE,MAAI,OAAO,MAAM,aAAa,YAAY,MAAM,SAAS,KAAK,EAAE,SAAS,GAAG;AAC1E,WAAO,EAAE,IAAI,OAAO,QAAQ,WAAW;AAAA,EACzC;AACA,MAAI,OAAO,MAAM,cAAc,YAAY,MAAM,YAAY,aAAa;AACxE,WAAO,EAAE,IAAI,OAAO,QAAQ,WAAW;AAAA,EACzC;AACA,SAAO,EAAE,IAAI,KAAK;AACpB;;;AC5IA,SAAS,MAAM,gBAAuD;AA2C/D,SAAS,mBACd,MACoD;AACpD,QAAM,eAAe,KAAK,gBAAgB;AAC1C,QAAM,cAAc,KAAK,eAAe;AACxC,QAAM,MAAM,KAAK,OAAO,KAAK;AAC7B,QAAM,cACJ,KAAK,sBAAsB;AAC7B,QAAM,SACJ,KAAK,gBAAgB;AAEvB,SAAO,OAAO,UAAU;AACtB,QAAI;AACJ,QAAI;AACF,aAAO,MAAM,MAAM,QAAQ,SAAS;AAAA,IACtC,QAAQ;AACN,cAAQ,MAAM,kBAAkB,KAAK,QAAQ,6BAA6B;AAC1E,aAAO,KAAK,KAAK,EAAE,OAAO,OAAO,CAAC;AAAA,IACpC;AAIA,UAAM,SAAS,iBAAiB;AAAA,MAC9B,UAAU,KAAK,IAAI,YAAY,GAAG,SAAS,KAAK;AAAA,MAChD,WAAW,UAAU,KAAK,IAAI,WAAW,GAAG,GAAG;AAAA,IACjD,CAAC;AACD,QAAI,CAAC,OAAO,IAAI;AAGd,YAAM,MAAM,KAAK,UAAU;AAC3B,UAAI,IAAI,OAAO,IAAI,OAAO;AACxB,cAAM,gBAAgB;AAAA,UACpB,KAAK,IAAI;AAAA,UACT,OAAO,IAAI;AAAA,UACX,QAAQ,OAAO;AAAA,UACf,OAAO,MAAM;AAAA,QACf,CAAC;AAAA,MACH;AACA,aAAO,QAAQ;AAAA,IACjB;AAEA,UAAM,EAAE,KAAK,MAAM,IAAI,KAAK,UAAU;AACtC,QAAI,CAAC,OAAO,CAAC,OAAO;AAClB,cAAQ,MAAM,8CAA8C,KAAK,QAAQ,EAAE;AAC3E,aAAO,KAAK,KAAK,EAAE,OAAO,YAAY,CAAC;AAAA,IACzC;AAEA,UAAM,SAAS,MAAM,eAAe;AAAA,MAClC;AAAA,MACA;AAAA,MACA,OAAO,MAAM;AAAA,MACb,SAAS,EAAE,GAAG,KAAK,aAAa,MAAM,KAAK,GAAG,UAAU,KAAK,SAAS;AAAA,IACxE,CAAC;AACD,QAAI,CAAC,OAAO,IAAI;AACd,cAAQ,MAAM,kBAAkB,KAAK,QAAQ,WAAM,OAAO,MAAM,KAAK,OAAO,KAAK,EAAE;AACnF,aAAO,KAAK,KAAK,EAAE,OAAO,OAAO,CAAC;AAAA,IACpC;AACA,WAAO,QAAQ;AAAA,EACjB;AAKA,WAAS,UAA6B;AACpC,QAAI,KAAK,WAAY,UAAS,KAAK,KAAK,UAAU;AAClD,WAAO,EAAE,SAAS,KAAK;AAAA,EACzB;AACF;AAKA,SAAS,UAAU,OAAoC,KAAkC;AACvF,QAAM,KAAK,OAAO,KAAK;AACvB,MAAI,CAAC,OAAO,SAAS,EAAE,KAAK,MAAM,EAAG,QAAO;AAK5C,SAAO,KAAK,IAAI,GAAG,IAAI,IAAI,EAAE;AAC/B;;;AC3HA,SAAS,YAA+B;AAkCxC,SAAS,WAAW,GAA2B;AAC7C,SAAO,OAAO,MAAM,YAAa,sBAA4C,SAAS,CAAC;AACzF;AAEA,SAAS,IAAI,GAAgC;AAC3C,SAAO,OAAO,MAAM,WAAW,IAAI;AACrC;AAQO,SAAS,qBACd,MAC4C;AAC5C,QAAM,eAAe,KAAK,gBAAgB;AAC1C,QAAM,cACJ,KAAK,sBAAsB;AAC7B,QAAM,SACJ,KAAK,gBAAgB;AAEvB,SAAO,OAAO,UAAU;AACtB,QAAI;AACJ,QAAI;AACF,YAAM,SAAkB,MAAM,MAAM,QAAQ,KAAK;AACjD,UAAI,CAAC,UAAU,OAAO,WAAW,SAAU,OAAM,IAAI,MAAM,uBAAuB;AAClF,aAAO;AAAA,IACT,QAAQ;AACN,cAAQ,MAAM,0CAA0C;AACxD,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC3D;AAKA,UAAM,SAAS,iBAAiB,EAAE,UAAU,IAAI,KAAK,YAAY,CAAC,KAAK,KAAK,CAAC;AAC7E,QAAI,CAAC,OAAO,IAAI;AAGd,YAAM,MAAM,KAAK,UAAU;AAC3B,UAAI,IAAI,OAAO,IAAI,OAAO;AACxB,cAAM,gBAAgB;AAAA,UACpB,KAAK,IAAI;AAAA,UACT,OAAO,IAAI;AAAA,UACX,QAAQ,OAAO;AAAA,UACf,OAAO,MAAM;AAAA,QACf,CAAC;AAAA,MACH;AACA,aAAO,KAAK,EAAE,IAAI,KAAK,CAAC;AAAA,IAC1B;AAKA,QAAI;AACJ,QAAI;AACF,gBAAU;AAAA,QACR,GAAG,KAAK,aAAa,MAAM,KAAK;AAAA,QAChC,GAAI,KAAK,WAAW,EAAE,UAAU,KAAK,SAAS,IAAI,CAAC;AAAA,MACrD;AAAA,IACF,SAAS,KAAK;AACZ,cAAQ,MAAM,sCAAsC,OAAO,GAAG,CAAC,EAAE;AACjE,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC3D;AACA,QAAI,CAAC,WAAW,QAAQ,QAAQ,GAAG;AACjC,cAAQ,MAAM,oCAAoC,OAAO,QAAQ,QAAQ,CAAC,EAAE;AAC5E,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC3D;AAEA,UAAM,EAAE,KAAK,MAAM,IAAI,KAAK,UAAU;AACtC,QAAI,CAAC,OAAO,CAAC,OAAO;AAClB,cAAQ,MAAM,8CAA8C,QAAQ,QAAQ,EAAE;AAC9E,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,YAAY,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAChE;AAEA,UAAM,SAAS,MAAM,eAAe,EAAE,KAAK,OAAO,OAAO,MAAM,OAAO,QAAQ,CAAC;AAC/E,QAAI,CAAC,OAAO,IAAI;AACd,cAAQ,MAAM,kBAAkB,QAAQ,QAAQ,WAAM,OAAO,MAAM,KAAK,OAAO,KAAK,EAAE;AACtF,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC3D;AACA,WAAO,KAAK,EAAE,IAAI,KAAK,CAAC;AAAA,EAC1B;AACF;","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":";;;;;AA+CA,eAAsB,eAAe,MAA0D;AAC7F,QAAM,UAAU,KAAK,SAAS;AAC9B,MAAI;AACJ,MAAI;AACF,UAAM,MAAM,QAAQ,KAAK,KAAK;AAAA,MAC5B,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,oBAAoB,iBAAiB,KAAK,MAAM;AAAA,MAC3E,MAAM,KAAK,UAAU,KAAK,OAAO;AAAA,IACnC,CAAC;AAAA,EACH,SAAS,KAAK;AACZ,WAAO,EAAE,IAAI,OAAO,QAAQ,GAAG,OAAO,kBAAkB,OAAO,GAAG,CAAC,GAAG;AAAA,EACxE;AACA,MAAI,OAAgB;AACpB,MAAI;AACF,WAAO,MAAM,IAAI,KAAK;AAAA,EACxB,QAAQ;AAAA,EAER;AACA,QAAM,MAAM,QAAQ,OAAO,SAAS,WAAY,OAAmC;AACnF,MAAI,IAAI,MAAM,OAAO,IAAI,OAAO,MAAM;AACpC,WAAO,EAAE,IAAI,MAAM,IAAI,OAAO,IAAI,MAAM,EAAE,EAAE;AAAA,EAC9C;AACA,QAAM,QAAQ,OAAO,OAAO,IAAI,UAAU,WAAW,IAAI,QAAQ,kBAAkB,IAAI,MAAM;AAC7F,SAAO,EAAE,IAAI,OAAO,QAAQ,IAAI,QAAQ,MAAM;AAChD;AAiBA,eAAsB,gBACpB,MAC0C;AAC1C,QAAM,UAAU,KAAK,SAAS;AAC9B,QAAM,aAAa,IAAI,gBAAgB;AACvC,QAAM,QAAQ,WAAW,MAAM,WAAW,MAAM,GAAG,KAAK,aAAa,IAAI;AACzE,MAAI;AACF,UAAM,MAAM,MAAM,QAAQ,KAAK,KAAK;AAAA,MAClC,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,oBAAoB,iBAAiB,KAAK,MAAM;AAAA,MAC3E,MAAM,KAAK,UAAU,EAAE,WAAW,KAAK,OAAO,CAAC;AAAA,MAC/C,QAAQ,WAAW;AAAA,IACrB,CAAC;AACD,WAAO,EAAE,IAAI,IAAI,IAAI,QAAQ,IAAI,OAAO;AAAA,EAC1C,QAAQ;AACN,WAAO,EAAE,IAAI,OAAO,QAAQ,EAAE;AAAA,EAChC,UAAE;AACA,iBAAa,KAAK;AAAA,EACpB;AACF;AAeO,IAAM,cAAc;AAapB,SAAS,iBAAiB,OAAkC;AACjE,MAAI,OAAO,MAAM,aAAa,YAAY,MAAM,SAAS,KAAK,EAAE,SAAS,GAAG;AAC1E,WAAO,EAAE,IAAI,OAAO,QAAQ,WAAW;AAAA,EACzC;AACA,MAAI,OAAO,MAAM,cAAc,YAAY,MAAM,YAAY,aAAa;AACxE,WAAO,EAAE,IAAI,OAAO,QAAQ,WAAW;AAAA,EACzC;AACA,SAAO,EAAE,IAAI,KAAK;AACpB;;;AC/IA,SAAS,MAAM,gBAAuD;;;ACsD/D,SAAS,oBACd,OACA,gBAC4B;AAC5B,QAAM,OAAuB,CAAC;AAE9B,QAAM,QAAQ,OAAO,mBAAmB,WAAW,eAAe,KAAK,IAAI;AAC3E,MAAI,MAAO,MAAK,iBAAiB;AAEjC,MAAI,OAAO,MAAM,qBAAqB,YAAY;AAChD,QAAI;AACF,YAAM,KAAK,MAAM,iBAAiB;AAClC,UAAI,OAAO,OAAO,YAAY,GAAG,KAAK,EAAG,MAAK,WAAW,GAAG,KAAK;AAAA,IACnE,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,KAAK,MAAM,SAAS,SAAS,MAAM,YAAY;AACrD,MAAI,OAAO,OAAO,YAAY,GAAG,KAAK,EAAG,MAAK,YAAY,GAAG,KAAK;AAElE,SAAO,OAAO,KAAK,IAAI,EAAE,SAAS,IAAI,OAAO;AAC/C;;;AD9BO,SAAS,mBACd,MACoD;AACpD,QAAM,eAAe,KAAK,gBAAgB;AAC1C,QAAM,cAAc,KAAK,eAAe;AACxC,QAAM,qBAAqB,KAAK,sBAAsB;AACtD,QAAM,MAAM,KAAK,OAAO,KAAK;AAC7B,QAAM,cACJ,KAAK,sBAAsB;AAC7B,QAAM,SACJ,KAAK,gBAAgB;AAEvB,SAAO,OAAO,UAAU;AACtB,QAAI;AACJ,QAAI;AACF,aAAO,MAAM,MAAM,QAAQ,SAAS;AAAA,IACtC,QAAQ;AACN,cAAQ,MAAM,kBAAkB,KAAK,QAAQ,6BAA6B;AAC1E,aAAO,KAAK,KAAK,EAAE,OAAO,OAAO,CAAC;AAAA,IACpC;AAIA,UAAM,SAAS,iBAAiB;AAAA,MAC9B,UAAU,KAAK,IAAI,YAAY,GAAG,SAAS,KAAK;AAAA,MAChD,WAAW,UAAU,KAAK,IAAI,WAAW,GAAG,GAAG;AAAA,IACjD,CAAC;AACD,QAAI,CAAC,OAAO,IAAI;AAGd,YAAM,MAAM,KAAK,UAAU;AAC3B,UAAI,IAAI,OAAO,IAAI,OAAO;AACxB,cAAM,gBAAgB;AAAA,UACpB,KAAK,IAAI;AAAA,UACT,OAAO,IAAI;AAAA,UACX,QAAQ,OAAO;AAAA,UACf,OAAO,MAAM;AAAA,QACf,CAAC;AAAA,MACH;AACA,aAAO,QAAQ;AAAA,IACjB;AAEA,UAAM,EAAE,KAAK,MAAM,IAAI,KAAK,UAAU;AACtC,QAAI,CAAC,OAAO,CAAC,OAAO;AAClB,cAAQ,MAAM,8CAA8C,KAAK,QAAQ,EAAE;AAC3E,aAAO,KAAK,KAAK,EAAE,OAAO,YAAY,CAAC;AAAA,IACzC;AAEA,UAAM,SAAS,MAAM,eAAe;AAAA,MAClC;AAAA,MACA;AAAA,MACA,OAAO,MAAM;AAAA,MACb,SAAS;AAAA,QACP,GAAG,KAAK,aAAa,MAAM,KAAK;AAAA,QAChC,UAAU,KAAK;AAAA,QACf,OAAO,oBAAoB,OAAO,KAAK,IAAI,kBAAkB,GAAG,SAAS,CAAC;AAAA,MAC5E;AAAA,IACF,CAAC;AACD,QAAI,CAAC,OAAO,IAAI;AACd,cAAQ,MAAM,kBAAkB,KAAK,QAAQ,WAAM,OAAO,MAAM,KAAK,OAAO,KAAK,EAAE;AACnF,aAAO,KAAK,KAAK,EAAE,OAAO,OAAO,CAAC;AAAA,IACpC;AACA,WAAO,QAAQ;AAAA,EACjB;AAKA,WAAS,UAA6B;AACpC,QAAI,KAAK,WAAY,UAAS,KAAK,KAAK,UAAU;AAClD,WAAO,EAAE,SAAS,KAAK;AAAA,EACzB;AACF;AAKA,SAAS,UAAU,OAAoC,KAAkC;AACvF,QAAM,KAAK,OAAO,KAAK;AACvB,MAAI,CAAC,OAAO,SAAS,EAAE,KAAK,MAAM,EAAG,QAAO;AAK5C,SAAO,KAAK,IAAI,GAAG,IAAI,IAAI,EAAE;AAC/B;;;AEnIA,SAAS,YAA+B;AAqCxC,SAAS,WAAW,GAA2B;AAC7C,SAAO,OAAO,MAAM,YAAa,sBAA4C,SAAS,CAAC;AACzF;AAEA,SAAS,IAAI,GAAgC;AAC3C,SAAO,OAAO,MAAM,WAAW,IAAI;AACrC;AAQO,SAAS,qBACd,MAC4C;AAC5C,QAAM,eAAe,KAAK,gBAAgB;AAC1C,QAAM,qBAAqB,KAAK,sBAAsB;AACtD,QAAM,cACJ,KAAK,sBAAsB;AAC7B,QAAM,SACJ,KAAK,gBAAgB;AAEvB,SAAO,OAAO,UAAU;AACtB,QAAI;AACJ,QAAI;AACF,YAAM,SAAkB,MAAM,MAAM,QAAQ,KAAK;AACjD,UAAI,CAAC,UAAU,OAAO,WAAW,SAAU,OAAM,IAAI,MAAM,uBAAuB;AAClF,aAAO;AAAA,IACT,QAAQ;AACN,cAAQ,MAAM,0CAA0C;AACxD,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC3D;AAKA,UAAM,SAAS,iBAAiB,EAAE,UAAU,IAAI,KAAK,YAAY,CAAC,KAAK,KAAK,CAAC;AAC7E,QAAI,CAAC,OAAO,IAAI;AAGd,YAAM,MAAM,KAAK,UAAU;AAC3B,UAAI,IAAI,OAAO,IAAI,OAAO;AACxB,cAAM,gBAAgB;AAAA,UACpB,KAAK,IAAI;AAAA,UACT,OAAO,IAAI;AAAA,UACX,QAAQ,OAAO;AAAA,UACf,OAAO,MAAM;AAAA,QACf,CAAC;AAAA,MACH;AACA,aAAO,KAAK,EAAE,IAAI,KAAK,CAAC;AAAA,IAC1B;AAKA,QAAI;AACJ,QAAI;AACF,gBAAU;AAAA,QACR,GAAG,KAAK,aAAa,MAAM,KAAK;AAAA,QAChC,GAAI,KAAK,WAAW,EAAE,UAAU,KAAK,SAAS,IAAI,CAAC;AAAA,QACnD,OAAO,oBAAoB,OAAO,IAAI,KAAK,kBAAkB,CAAC,CAAC;AAAA,MACjE;AAAA,IACF,SAAS,KAAK;AACZ,cAAQ,MAAM,sCAAsC,OAAO,GAAG,CAAC,EAAE;AACjE,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC3D;AACA,QAAI,CAAC,WAAW,QAAQ,QAAQ,GAAG;AACjC,cAAQ,MAAM,oCAAoC,OAAO,QAAQ,QAAQ,CAAC,EAAE;AAC5E,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC3D;AAEA,UAAM,EAAE,KAAK,MAAM,IAAI,KAAK,UAAU;AACtC,QAAI,CAAC,OAAO,CAAC,OAAO;AAClB,cAAQ,MAAM,8CAA8C,QAAQ,QAAQ,EAAE;AAC9E,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,YAAY,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAChE;AAEA,UAAM,SAAS,MAAM,eAAe,EAAE,KAAK,OAAO,OAAO,MAAM,OAAO,QAAQ,CAAC;AAC/E,QAAI,CAAC,OAAO,IAAI;AACd,cAAQ,MAAM,kBAAkB,QAAQ,QAAQ,WAAM,OAAO,MAAM,KAAK,OAAO,KAAK,EAAE;AACtF,aAAO,KAAK,EAAE,IAAI,OAAO,OAAO,OAAO,GAAG,EAAE,QAAQ,IAAI,CAAC;AAAA,IAC3D;AACA,WAAO,KAAK,EAAE,IAAI,KAAK,CAAC;AAAA,EAC1B;AACF;","names":[]}
|
|
@@ -3,17 +3,17 @@ import {
|
|
|
3
3
|
} from "./chunk-PZQ3WED2.js";
|
|
4
4
|
import {
|
|
5
5
|
formatFleetWriteSummary
|
|
6
|
-
} from "./chunk-
|
|
7
|
-
import "./chunk-
|
|
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-
|
|
15
|
-
import "./chunk-
|
|
16
|
-
import "./chunk-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
27
|
+
} from "./chunk-ULL4AD3Y.js";
|
|
28
28
|
import "./chunk-6LIWDIXU.js";
|
|
29
29
|
import {
|
|
30
30
|
sendApprovedReports
|
|
31
|
-
} from "./chunk-
|
|
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-
|
|
68
|
-
import "./chunk-
|
|
69
|
-
import "./chunk-
|
|
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-
|
|
81
|
+
} from "./chunk-Y4O44LZB.js";
|
|
82
82
|
import "./chunk-U5SZ4FOP.js";
|
|
83
83
|
import {
|
|
84
84
|
SEVERITY_RANK,
|
|
85
85
|
siteSlug
|
|
86
|
-
} from "./chunk-
|
|
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
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
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 (${
|
|
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
|
-
|
|
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>
|