@reddoorla/maintenance 0.78.0 → 0.80.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/{announce-RUT247HS.js → announce-KZDU65IF.js} +6 -5
- package/dist/{announce-RUT247HS.js.map → announce-KZDU65IF.js.map} +1 -1
- package/dist/{chunk-FVVGDIGX.js → chunk-42A4H6SF.js} +2 -2
- package/dist/{chunk-SEQA7MC3.js → chunk-62VF32IC.js} +2 -2
- package/dist/{chunk-6SZBNALD.js → chunk-6M2IXJ3E.js} +3 -3
- package/dist/{chunk-MNE374CZ.js → chunk-6QRLRT3P.js} +4 -16
- package/dist/chunk-6QRLRT3P.js.map +1 -0
- package/dist/{chunk-QMXQUL5Z.js → chunk-7ADQL5Y3.js} +2 -2
- package/dist/chunk-A3WDWNN5.js +70 -0
- package/dist/chunk-A3WDWNN5.js.map +1 -0
- package/dist/{chunk-QWFHC3VH.js → chunk-AKRUYPLU.js} +3 -3
- package/dist/{chunk-YSPA5AUT.js → chunk-JMAFXEQE.js} +36 -8
- package/dist/{chunk-YSPA5AUT.js.map → chunk-JMAFXEQE.js.map} +1 -1
- package/dist/{chunk-KPQXB5CR.js → chunk-KKIKJWM2.js} +2 -2
- package/dist/chunk-N4MOEF32.js +62 -0
- package/dist/{chunk-LJPCMXR7.js.map → chunk-N4MOEF32.js.map} +1 -1
- package/dist/{chunk-EYODPUHV.js → chunk-NMAWXBJA.js} +13 -2
- package/dist/chunk-NMAWXBJA.js.map +1 -0
- package/dist/{chunk-YIQ7IWE3.js → chunk-R5KAXGSP.js} +3 -3
- package/dist/chunk-SEEA6RXF.js +18 -0
- package/dist/chunk-SEEA6RXF.js.map +1 -0
- package/dist/{chunk-GWAOX3CU.js → chunk-TEOENEJH.js} +2 -2
- package/dist/{chunk-VSHC4HOI.js → chunk-YAMRVXOD.js} +93 -1
- package/dist/chunk-YAMRVXOD.js.map +1 -0
- package/dist/cli/bin.js +22 -10
- package/dist/cli/bin.js.map +1 -1
- package/dist/cli/commands/audit.js +6 -4
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/configs/lighthouse.d.ts +1 -1
- package/dist/configs/lighthouse.js +1 -1
- package/dist/configs/playwright-a11y.js +1 -1
- package/dist/{digest-NQAQI34P.js → digest-N232AYVG.js} +8 -6
- package/dist/{digest-NQAQI34P.js.map → digest-N232AYVG.js.map} +1 -1
- package/dist/forms/index.js +6 -62
- package/dist/forms/index.js.map +1 -1
- package/dist/forms-notify-target-ZDQUEEEY.js +253 -0
- package/dist/forms-notify-target-ZDQUEEEY.js.map +1 -0
- package/dist/{github-signals-4LWQGD5V.js → github-signals-2BFMZLPV.js} +2 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +20 -17
- package/dist/index.js.map +1 -1
- package/dist/{init-KF4QHMWQ.js → init-RYKQFSZI.js} +8 -6
- package/dist/{init-KF4QHMWQ.js.map → init-RYKQFSZI.js.map} +1 -1
- package/dist/{launch-CVCIP5NU.js → launch-EGKYS3BD.js} +11 -8
- package/dist/{launch-CVCIP5NU.js.map → launch-EGKYS3BD.js.map} +1 -1
- package/dist/{orchestrate-QOQXOJLI.js → orchestrate-NY6L4Q7Q.js} +4 -3
- package/dist/{preflight-2PWVVIRR.js → preflight-SRDQS7Y6.js} +5 -4
- package/dist/{preflight-2PWVVIRR.js.map → preflight-SRDQS7Y6.js.map} +1 -1
- package/dist/{protection-audit-X3BHT2MB.js → protection-audit-2UR45SOC.js} +30 -2
- package/dist/protection-audit-2UR45SOC.js.map +1 -0
- package/dist/{renovate-dispatch-2VYCEH42.js → renovate-dispatch-7D7FMMJP.js} +2 -2
- package/dist/{report-IU4CLJS5.js → report-BGKMN5GU.js} +9 -7
- package/dist/{report-IU4CLJS5.js.map → report-BGKMN5GU.js.map} +1 -1
- package/dist/{self-updating-ALMUWJNX.js → self-updating-WJEHPASK.js} +3 -3
- package/dist/{selftest-PJFABZDM.js → selftest-ZJDB4JQB.js} +7 -6
- package/dist/{selftest-PJFABZDM.js.map → selftest-ZJDB4JQB.js.map} +1 -1
- package/package.json +1 -1
- package/dist/chunk-EYODPUHV.js.map +0 -1
- package/dist/chunk-LJPCMXR7.js +0 -44
- package/dist/chunk-MNE374CZ.js.map +0 -1
- package/dist/chunk-VSHC4HOI.js.map +0 -1
- package/dist/protection-audit-X3BHT2MB.js.map +0 -1
- /package/dist/{chunk-FVVGDIGX.js.map → chunk-42A4H6SF.js.map} +0 -0
- /package/dist/{chunk-SEQA7MC3.js.map → chunk-62VF32IC.js.map} +0 -0
- /package/dist/{chunk-6SZBNALD.js.map → chunk-6M2IXJ3E.js.map} +0 -0
- /package/dist/{chunk-QMXQUL5Z.js.map → chunk-7ADQL5Y3.js.map} +0 -0
- /package/dist/{chunk-QWFHC3VH.js.map → chunk-AKRUYPLU.js.map} +0 -0
- /package/dist/{chunk-KPQXB5CR.js.map → chunk-KKIKJWM2.js.map} +0 -0
- /package/dist/{chunk-YIQ7IWE3.js.map → chunk-R5KAXGSP.js.map} +0 -0
- /package/dist/{chunk-GWAOX3CU.js.map → chunk-TEOENEJH.js.map} +0 -0
- /package/dist/{github-signals-4LWQGD5V.js.map → github-signals-2BFMZLPV.js.map} +0 -0
- /package/dist/{orchestrate-QOQXOJLI.js.map → orchestrate-NY6L4Q7Q.js.map} +0 -0
- /package/dist/{renovate-dispatch-2VYCEH42.js.map → renovate-dispatch-7D7FMMJP.js.map} +0 -0
- /package/dist/{self-updating-ALMUWJNX.js.map → self-updating-WJEHPASK.js.map} +0 -0
package/dist/forms/index.js
CHANGED
|
@@ -1,69 +1,13 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MIN_FILL_MS,
|
|
3
|
+
screenSubmission,
|
|
4
|
+
submitScreenOut,
|
|
5
|
+
submitToIngest
|
|
6
|
+
} from "../chunk-A3WDWNN5.js";
|
|
1
7
|
import {
|
|
2
8
|
SUBMISSION_FORM_TYPES
|
|
3
9
|
} from "../chunk-2OP4JKIQ.js";
|
|
4
10
|
|
|
5
|
-
// src/forms/client.ts
|
|
6
|
-
var INGEST_TIMEOUT_MS = 8e3;
|
|
7
|
-
async function submitToIngest(opts) {
|
|
8
|
-
const doFetch = opts.fetch ?? fetch;
|
|
9
|
-
const controller = new AbortController();
|
|
10
|
-
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? INGEST_TIMEOUT_MS);
|
|
11
|
-
let res;
|
|
12
|
-
try {
|
|
13
|
-
res = await doFetch(opts.url, {
|
|
14
|
-
method: "POST",
|
|
15
|
-
headers: { "content-type": "application/json", "x-forms-token": opts.token },
|
|
16
|
-
body: JSON.stringify(opts.payload),
|
|
17
|
-
signal: controller.signal
|
|
18
|
-
});
|
|
19
|
-
} catch (err) {
|
|
20
|
-
return { ok: false, status: 0, error: `network error: ${String(err)}` };
|
|
21
|
-
} finally {
|
|
22
|
-
clearTimeout(timer);
|
|
23
|
-
}
|
|
24
|
-
let body = null;
|
|
25
|
-
try {
|
|
26
|
-
body = await res.json();
|
|
27
|
-
} catch {
|
|
28
|
-
}
|
|
29
|
-
const obj = body && typeof body === "object" ? body : null;
|
|
30
|
-
if (res.ok && obj && obj.ok === true) {
|
|
31
|
-
return { ok: true, id: String(obj.id ?? "") };
|
|
32
|
-
}
|
|
33
|
-
const error = obj && typeof obj.error === "string" ? obj.error : `ingest failed (${res.status})`;
|
|
34
|
-
return { ok: false, status: res.status, error };
|
|
35
|
-
}
|
|
36
|
-
async function submitScreenOut(opts) {
|
|
37
|
-
const doFetch = opts.fetch ?? fetch;
|
|
38
|
-
const controller = new AbortController();
|
|
39
|
-
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 1500);
|
|
40
|
-
try {
|
|
41
|
-
const res = await doFetch(opts.url, {
|
|
42
|
-
method: "POST",
|
|
43
|
-
headers: { "content-type": "application/json", "x-forms-token": opts.token },
|
|
44
|
-
// `_screenOut` is a reserved wire key (underscore-namespaced like `_meta`)
|
|
45
|
-
// so a site's real form field named "screenOut" can never read as a beacon.
|
|
46
|
-
body: JSON.stringify({ _screenOut: opts.reason }),
|
|
47
|
-
signal: controller.signal
|
|
48
|
-
});
|
|
49
|
-
return { ok: res.ok, status: res.status };
|
|
50
|
-
} catch {
|
|
51
|
-
return { ok: false, status: 0 };
|
|
52
|
-
} finally {
|
|
53
|
-
clearTimeout(timer);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
var MIN_FILL_MS = 800;
|
|
57
|
-
function screenSubmission(input) {
|
|
58
|
-
if (typeof input.botField === "string" && input.botField.trim().length > 0) {
|
|
59
|
-
return { ok: false, reason: "honeypot" };
|
|
60
|
-
}
|
|
61
|
-
if (typeof input.elapsedMs === "number" && input.elapsedMs < MIN_FILL_MS) {
|
|
62
|
-
return { ok: false, reason: "too-fast" };
|
|
63
|
-
}
|
|
64
|
-
return { ok: true };
|
|
65
|
-
}
|
|
66
|
-
|
|
67
11
|
// src/forms/action.ts
|
|
68
12
|
import { fail, redirect } from "@sveltejs/kit";
|
|
69
13
|
|
package/dist/forms/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
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 } | { 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 /** Abort budget for the central call. Default INGEST_TIMEOUT_MS. */\n timeoutMs?: number;\n};\n\n/** Default abort budget for the site→central ingest call. A central function hung\n * mid-deploy otherwise leaves the visitor's submit awaiting until Netlify kills\n * the SITE function at its 10s sync limit — a broken response instead of the\n * friendly error copy (espada's 2026-07-10 form-e2e warns caught exactly this).\n * 8s clears warm (~0.3s) and cold (~1-3s) central latency by a wide margin while\n * leaving the action ~2s of the 10s envelope to respond cleanly. */\nexport const INGEST_TIMEOUT_MS = 8000;\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 const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? INGEST_TIMEOUT_MS);\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 signal: controller.signal,\n });\n } catch (err) {\n return { ok: false, status: 0, error: `network error: ${String(err)}` };\n } finally {\n clearTimeout(timer);\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 // `_screenOut` is a reserved wire key (underscore-namespaced like `_meta`)\n // so a site's real form field named \"screenOut\" can never read as a beacon.\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 // buildPayload runs on untrusted form data; a careless field access (e.g.\n // `form.get(\"email\")!.toString()` on an absent field) would otherwise escape\n // as an uncaught 500. Treat a throw as a malformed request (400), mirroring\n // endpoint.ts's guard and its \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...opts.buildPayload(form, event),\n formType: opts.formType,\n _meta: buildSubmissionMeta(event, form.get(turnstileFieldName)?.toString()),\n };\n } catch (err) {\n console.error(`[forms-ingest] ${opts.formType}: buildPayload threw: ${String(err)}`);\n return fail(400, { error: failed });\n }\n\n const result = await submitToIngest({\n url,\n token,\n fetch: event.fetch,\n payload,\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.\n * (Older package versions also sent a `userAgent` — central never consumed it,\n * so it is no longer forwarded; `readMeta` simply ignores it from old senders.) */\nexport type SubmissionMeta = {\n turnstileToken?: string;\n clientIp?: 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 * among the KNOWN keys, dropping everything else — a bot cannot smuggle a\n * non-string clientIp in, and unknown fields (e.g. the `userAgent` older package\n * versions still send) are silently ignored. The token/IP read here are used\n * transiently (Turnstile verify + `remoteip`) and are NEVER persisted; the token\n * is never 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 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`).\n */\ntype MetaEvent = {\n getClientAddress?: () => string;\n};\n\n/**\n * Build the transient `_meta` envelope a site forwards to central ingest:\n * `{ turnstileToken?, clientIp? }`. Returns `undefined` when no field yields a\n * value so callers can attach it unconditionally without polluting the payload\n * (an `undefined` value is dropped by `JSON.stringify`). `getClientAddress` is\n * guarded (some adapters lack a client address and can throw). The visitor's\n * user-agent is deliberately NOT forwarded — central never consumed it, so\n * shipping it was pure transient-PII surface. 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 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":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/forms/action.ts","../../src/forms/meta.ts","../../src/forms/endpoint.ts"],"sourcesContent":["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 // buildPayload runs on untrusted form data; a careless field access (e.g.\n // `form.get(\"email\")!.toString()` on an absent field) would otherwise escape\n // as an uncaught 500. Treat a throw as a malformed request (400), mirroring\n // endpoint.ts's guard and its \"never 500s\" guarantee.\n let payload: SubmissionPayload;\n try {\n payload = {\n ...opts.buildPayload(form, event),\n formType: opts.formType,\n _meta: buildSubmissionMeta(event, form.get(turnstileFieldName)?.toString()),\n };\n } catch (err) {\n console.error(`[forms-ingest] ${opts.formType}: buildPayload threw: ${String(err)}`);\n return fail(400, { error: failed });\n }\n\n const result = await submitToIngest({\n url,\n token,\n fetch: event.fetch,\n payload,\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.\n * (Older package versions also sent a `userAgent` — central never consumed it,\n * so it is no longer forwarded; `readMeta` simply ignores it from old senders.) */\nexport type SubmissionMeta = {\n turnstileToken?: string;\n clientIp?: 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 * among the KNOWN keys, dropping everything else — a bot cannot smuggle a\n * non-string clientIp in, and unknown fields (e.g. the `userAgent` older package\n * versions still send) are silently ignored. The token/IP read here are used\n * transiently (Turnstile verify + `remoteip`) and are NEVER persisted; the token\n * is never 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 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`).\n */\ntype MetaEvent = {\n getClientAddress?: () => string;\n};\n\n/**\n * Build the transient `_meta` envelope a site forwards to central ingest:\n * `{ turnstileToken?, clientIp? }`. Returns `undefined` when no field yields a\n * value so callers can attach it unconditionally without polluting the payload\n * (an `undefined` value is dropped by `JSON.stringify`). `getClientAddress` is\n * guarded (some adapters lack a client address and can throw). The visitor's\n * user-agent is deliberately NOT forwarded — central never consumed it, so\n * shipping it was pure transient-PII surface. 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 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":";;;;;;;;;;;AAAA,SAAS,MAAM,gBAAuD;;;ACqD/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,SAAO,OAAO,KAAK,IAAI,EAAE,SAAS,IAAI,OAAO;AAC/C;;;AD1BO,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;AAMA,QAAI;AACJ,QAAI;AACF,gBAAU;AAAA,QACR,GAAG,KAAK,aAAa,MAAM,KAAK;AAAA,QAChC,UAAU,KAAK;AAAA,QACf,OAAO,oBAAoB,OAAO,KAAK,IAAI,kBAAkB,GAAG,SAAS,CAAC;AAAA,MAC5E;AAAA,IACF,SAAS,KAAK;AACZ,cAAQ,MAAM,kBAAkB,KAAK,QAAQ,yBAAyB,OAAO,GAAG,CAAC,EAAE;AACnF,aAAO,KAAK,KAAK,EAAE,OAAO,OAAO,CAAC;AAAA,IACpC;AAEA,UAAM,SAAS,MAAM,eAAe;AAAA,MAClC;AAAA,MACA;AAAA,MACA,OAAO,MAAM;AAAA,MACb;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;;;AE/IA,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":[]}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import "./chunk-SEEA6RXF.js";
|
|
2
|
+
import {
|
|
3
|
+
listWebsites,
|
|
4
|
+
siteSlug,
|
|
5
|
+
updateSiteField
|
|
6
|
+
} from "./chunk-GXVX4HUH.js";
|
|
7
|
+
import {
|
|
8
|
+
openBase,
|
|
9
|
+
readAirtableConfig
|
|
10
|
+
} from "./chunk-HN6I2XWI.js";
|
|
11
|
+
import "./chunk-6VAL7XJT.js";
|
|
12
|
+
|
|
13
|
+
// src/forms/notify.ts
|
|
14
|
+
var OPERATOR_FALLBACK = "tucker@reddoorla.com";
|
|
15
|
+
function operatorEmail() {
|
|
16
|
+
return process.env.OPERATOR_EMAIL?.trim() || OPERATOR_FALLBACK;
|
|
17
|
+
}
|
|
18
|
+
function pocAddress(site) {
|
|
19
|
+
return site.pointOfContact ?? site.reportRecipientsTo ?? null;
|
|
20
|
+
}
|
|
21
|
+
function normalizeRecipients(v) {
|
|
22
|
+
const arr = Array.isArray(v) ? v : v ? [v] : [];
|
|
23
|
+
const seen = /* @__PURE__ */ new Set();
|
|
24
|
+
const out = [];
|
|
25
|
+
for (const raw of arr) {
|
|
26
|
+
if (typeof raw !== "string") continue;
|
|
27
|
+
const t = raw.trim();
|
|
28
|
+
if (t && !seen.has(t)) {
|
|
29
|
+
seen.add(t);
|
|
30
|
+
out.push(t);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function resolveRecipients(site, submission) {
|
|
36
|
+
if (site.status !== "maintenance") {
|
|
37
|
+
return { to: [operatorEmail()], cc: [] };
|
|
38
|
+
}
|
|
39
|
+
const routing = site.notifyRouting;
|
|
40
|
+
if (routing) {
|
|
41
|
+
const value = parseExtraFields(submission.extraFields)[routing.field];
|
|
42
|
+
const match = typeof value === "string" ? routing.routes[value] : void 0;
|
|
43
|
+
const to = normalizeRecipients(match ?? routing.default);
|
|
44
|
+
if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };
|
|
45
|
+
}
|
|
46
|
+
const poc = pocAddress(site);
|
|
47
|
+
return poc ? { to: [poc], cc: [] } : null;
|
|
48
|
+
}
|
|
49
|
+
function probeSubmission(extraFields) {
|
|
50
|
+
return {
|
|
51
|
+
id: "probe",
|
|
52
|
+
submissionId: null,
|
|
53
|
+
siteId: "probe",
|
|
54
|
+
formType: "contact",
|
|
55
|
+
name: "probe",
|
|
56
|
+
email: "probe@example.com",
|
|
57
|
+
phone: null,
|
|
58
|
+
message: null,
|
|
59
|
+
extraFields,
|
|
60
|
+
sourceUrl: null,
|
|
61
|
+
utm: null,
|
|
62
|
+
submittedAt: null,
|
|
63
|
+
status: "new",
|
|
64
|
+
notifyStatus: "pending",
|
|
65
|
+
resendMessageId: null
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
function describeNotifyTarget(site) {
|
|
69
|
+
if (site.status !== "maintenance") {
|
|
70
|
+
const guarded = resolveRecipients(site, probeSubmission(null));
|
|
71
|
+
return {
|
|
72
|
+
audience: "operator",
|
|
73
|
+
to: guarded?.to ?? [],
|
|
74
|
+
cc: [],
|
|
75
|
+
reason: `Status is ${site.status === null ? "blank" : `"${site.status}"`}, not "maintenance" \u2014 the pre-launch guard routes every notification to the operator.`
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const routing = site.notifyRouting;
|
|
79
|
+
if (!routing) {
|
|
80
|
+
const direct = resolveRecipients(site, probeSubmission(null));
|
|
81
|
+
if (!direct || direct.to.length === 0) {
|
|
82
|
+
return {
|
|
83
|
+
audience: "nobody",
|
|
84
|
+
to: [],
|
|
85
|
+
cc: [],
|
|
86
|
+
reason: `Status is "maintenance" but the site has no Point of Contact and no Notify Routing \u2014 the lead is stored and no notification is sent.`
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
audience: "client",
|
|
91
|
+
to: direct.to,
|
|
92
|
+
cc: direct.cc,
|
|
93
|
+
reason: `Status is "maintenance" \u2014 notifications go to the site's Point of Contact.`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const to = /* @__PURE__ */ new Set();
|
|
97
|
+
const cc = /* @__PURE__ */ new Set();
|
|
98
|
+
const values = [...Object.keys(routing.routes), "\0no-such-route"];
|
|
99
|
+
for (const value of values) {
|
|
100
|
+
const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));
|
|
101
|
+
r?.to.forEach((a) => to.add(a));
|
|
102
|
+
r?.cc.forEach((a) => cc.add(a));
|
|
103
|
+
}
|
|
104
|
+
if (to.size === 0) {
|
|
105
|
+
return {
|
|
106
|
+
audience: "nobody",
|
|
107
|
+
to: [],
|
|
108
|
+
cc: [],
|
|
109
|
+
reason: `Status is "maintenance" with Notify Routing on "${routing.field}", but no route, default or Point of Contact resolves to an address.`
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
audience: "client",
|
|
114
|
+
to: [...to],
|
|
115
|
+
cc: [...cc],
|
|
116
|
+
reason: `Status is "maintenance" with Notify Routing on "${routing.field}" \u2014 the recipient depends on that field's value; every address it could reach is listed.`
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function parseExtraFields(raw) {
|
|
120
|
+
if (!raw) return {};
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
}
|
|
128
|
+
return {};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// src/recipes/forms-notify-target.ts
|
|
132
|
+
var STATUS_COLUMN = "Status";
|
|
133
|
+
var LIVE_STATUS = "maintenance";
|
|
134
|
+
var VERIFY_STATUS = "launch period";
|
|
135
|
+
function findSite(rows, site) {
|
|
136
|
+
const wanted = site.trim().toLowerCase();
|
|
137
|
+
return rows.find(
|
|
138
|
+
(r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
async function formsNotifyTarget(deps) {
|
|
142
|
+
const base = deps.base ?? openBase(readAirtableConfig());
|
|
143
|
+
const rows = await listWebsites(base);
|
|
144
|
+
const row = findSite(rows, deps.site);
|
|
145
|
+
if (!row) {
|
|
146
|
+
const needle = siteSlug(deps.site);
|
|
147
|
+
const near = rows.map((r) => r.name).filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));
|
|
148
|
+
throw Object.assign(
|
|
149
|
+
new Error(
|
|
150
|
+
`No Websites row matches '${deps.site}'. The Websites NAME is not always the repo slug (Sonder, not gallerysonder).` + (near.length > 0 ? ` Did you mean: ${near.join(", ")}?` : "")
|
|
151
|
+
),
|
|
152
|
+
{ exitCode: 2 }
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
if (!deps.set) {
|
|
156
|
+
return { site: row.name, status: row.status, target: describeNotifyTarget(row) };
|
|
157
|
+
}
|
|
158
|
+
const to = deps.set === "on" ? VERIFY_STATUS : deps.restore?.trim();
|
|
159
|
+
if (deps.set === "off" && !to) {
|
|
160
|
+
throw Object.assign(
|
|
161
|
+
new Error(
|
|
162
|
+
`--set off needs --restore <status>: the status to return to is never inferred. Guessing "${LIVE_STATUS}" for a site that was "hosting" or "legacy" would start sending real client notifications \u2014 the inverse of the failure this command exists to prevent.`
|
|
163
|
+
),
|
|
164
|
+
{ exitCode: 2 }
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (deps.set === "on" && row.status !== LIVE_STATUS) {
|
|
168
|
+
throw Object.assign(
|
|
169
|
+
new Error(
|
|
170
|
+
`${row.name} is "${row.status ?? "blank"}", not "${LIVE_STATUS}" \u2014 notifications already go to the operator only, so there is nothing to flip. Leaving the status untouched.`
|
|
171
|
+
),
|
|
172
|
+
{ exitCode: 2 }
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
await updateSiteField(base, row.id, STATUS_COLUMN, to);
|
|
176
|
+
const after = findSite(await listWebsites(base), deps.site);
|
|
177
|
+
if (!after) {
|
|
178
|
+
throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {
|
|
179
|
+
exitCode: 1
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
site: after.name,
|
|
184
|
+
status: after.status,
|
|
185
|
+
target: describeNotifyTarget(after),
|
|
186
|
+
flip: { from: row.status, to, confirmed: after.status === to }
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// src/cli/commands/forms-notify-target.ts
|
|
191
|
+
var AUDIENCE_LABEL = {
|
|
192
|
+
client: "THE CLIENT",
|
|
193
|
+
operator: "OPERATOR ONLY",
|
|
194
|
+
nobody: "NOBODY"
|
|
195
|
+
};
|
|
196
|
+
function formatNotifyTarget(r) {
|
|
197
|
+
const lines = [];
|
|
198
|
+
if (r.flip) {
|
|
199
|
+
const arrow = `${r.flip.from ?? "blank"} \u2192 ${r.flip.to}`;
|
|
200
|
+
lines.push(
|
|
201
|
+
r.flip.confirmed ? `${r.site} Status: ${arrow} \u2713 confirmed by read-back` : `${r.site} Status: ${arrow} \u2717 NOT CONFIRMED \u2014 the row still reads "${r.status ?? "blank"}". Nothing was verified; do not test-submit.`
|
|
202
|
+
);
|
|
203
|
+
} else {
|
|
204
|
+
lines.push(`${r.site} Status: ${r.status ?? "blank"}`);
|
|
205
|
+
}
|
|
206
|
+
lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);
|
|
207
|
+
if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(", ")}`);
|
|
208
|
+
if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(", ")}`);
|
|
209
|
+
lines.push(` ${r.target.reason}`);
|
|
210
|
+
if (r.target.audience === "client") {
|
|
211
|
+
lines.push(
|
|
212
|
+
"",
|
|
213
|
+
`\u26A0\uFE0F A test submission WILL email the client, and email cannot be recalled.`,
|
|
214
|
+
` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {
|
|
218
|
+
lines.push(
|
|
219
|
+
"",
|
|
220
|
+
`Safe to test. When you are done, restore it:`,
|
|
221
|
+
` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return lines.join("\n");
|
|
225
|
+
}
|
|
226
|
+
async function runFormsNotifyTargetCommand(site, opts) {
|
|
227
|
+
if (!site?.trim()) {
|
|
228
|
+
return { output: "forms-notify-target requires <site> (slug or Airtable name)", code: 2 };
|
|
229
|
+
}
|
|
230
|
+
const set = opts.set?.trim().toLowerCase();
|
|
231
|
+
if (set !== void 0 && set !== "on" && set !== "off") {
|
|
232
|
+
return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const result = await formsNotifyTarget({
|
|
236
|
+
site: site.trim(),
|
|
237
|
+
...set ? { set } : {},
|
|
238
|
+
...opts.restore ? { restore: opts.restore } : {}
|
|
239
|
+
});
|
|
240
|
+
return {
|
|
241
|
+
output: formatNotifyTarget(result),
|
|
242
|
+
code: result.flip && !result.flip.confirmed ? 1 : 0
|
|
243
|
+
};
|
|
244
|
+
} catch (err) {
|
|
245
|
+
const e = err;
|
|
246
|
+
return { output: e.message ?? String(err), code: e.exitCode ?? 1 };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
export {
|
|
250
|
+
formatNotifyTarget,
|
|
251
|
+
runFormsNotifyTargetCommand
|
|
252
|
+
};
|
|
253
|
+
//# sourceMappingURL=forms-notify-target-ZDQUEEEY.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/forms/notify.ts","../src/recipes/forms-notify-target.ts","../src/cli/commands/forms-notify-target.ts"],"sourcesContent":["import type { WebsiteRow } from \"../reports/airtable/websites.js\";\nimport type { SubmissionRow, NotifyStatus } from \"../reports/submission-row.js\";\nimport type { ResendSendInput } from \"../reports/send/resend.js\";\nimport { escapeHtml } from \"../util/html.js\";\n\nconst FORMS_FROM = \"forms@reddoorla.com\";\nconst FALLBACK_REPLY_TO = \"info@reddoorla.com\";\n\n// Single-operator fleet fallback when OPERATOR_EMAIL is unset. Deliberately the\n// monitored personal inbox (not the digest's info@ alias) — a missed pre-launch\n// LEAD is higher-stakes than a missed digest, so it should land somewhere watched.\nconst OPERATOR_FALLBACK = \"tucker@reddoorla.com\";\n\nfunction operatorEmail(): string {\n return process.env.OPERATOR_EMAIL?.trim() || OPERATOR_FALLBACK;\n}\n\n/** Strip characters that would break an RFC 5322 display name. */\nfunction displayName(raw: string): string {\n return raw.replace(/[\"\\r\\n]/g, \"\").trim() || \"Reddoor\";\n}\n\nfunction pocAddress(site: WebsiteRow): string | null {\n return site.pointOfContact ?? site.reportRecipientsTo ?? null;\n}\n\n/** Coerce a string|string[] recipient config into a clean, de-duped address list. */\nfunction normalizeRecipients(v: string | string[] | undefined): string[] {\n const arr = Array.isArray(v) ? v : v ? [v] : [];\n const seen = new Set<string>();\n const out: string[] = [];\n for (const raw of arr) {\n if (typeof raw !== \"string\") continue;\n const t = raw.trim();\n if (t && !seen.has(t)) {\n seen.add(t);\n out.push(t);\n }\n }\n return out;\n}\n\nexport type Recipients = { to: string[]; cc: string[] };\n\n/**\n * Where a submission notification goes.\n * - Pre-launch (status !== \"maintenance\"): the operator only — no routing, no CC.\n * Preserves the verify guard (flip a site to \"launch period\" to route tests to\n * yourself).\n * - Maintenance + a `Notify Routing` config: address by the routing field's value\n * (`extraFields[field]`) → its matched route, else the config `default`; CC from\n * the config. If nothing resolves, fall through to the single POC.\n * - Maintenance, no routing: the single site POC (pointOfContact ?? reportRecipientsTo).\n * Returns null only when nothing resolves — the lead is still persisted; notify skips.\n */\nexport function resolveRecipients(site: WebsiteRow, submission: SubmissionRow): Recipients | null {\n if (site.status !== \"maintenance\") {\n return { to: [operatorEmail()], cc: [] };\n }\n const routing = site.notifyRouting;\n if (routing) {\n const value = parseExtraFields(submission.extraFields)[routing.field];\n const match = typeof value === \"string\" ? routing.routes[value] : undefined;\n const to = normalizeRecipients(match ?? routing.default);\n if (to.length > 0) return { to, cc: normalizeRecipients(routing.cc) };\n // routing matched nothing → fall through to the POC below\n }\n const poc = pocAddress(site);\n return poc ? { to: [poc], cc: [] } : null;\n}\n\n/** Who a submission to this site would notify, answered WITHOUT sending one. */\nexport type NotifyTarget = {\n /** `client` means a real point of contact receives it. `operator` means the\n * pre-launch guard is holding and only you do. `nobody` means nothing\n * resolves — the lead is still stored, but no notification goes out. */\n audience: \"client\" | \"operator\" | \"nobody\";\n /** Every address a submission could reach, across ALL routing branches. */\n to: string[];\n cc: string[];\n /** Plain-language reason, naming the field that decided it. */\n reason: string;\n};\n\n/** A submission that exists only to ask resolveRecipients a question. */\nfunction probeSubmission(extraFields: string | null): SubmissionRow {\n return {\n id: \"probe\",\n submissionId: null,\n siteId: \"probe\",\n formType: \"contact\" as SubmissionRow[\"formType\"],\n name: \"probe\",\n email: \"probe@example.com\",\n phone: null,\n message: null,\n extraFields,\n sourceUrl: null,\n utm: null,\n submittedAt: null,\n status: \"new\" as SubmissionRow[\"status\"],\n notifyStatus: \"pending\" as NotifyStatus,\n resendMessageId: null,\n };\n}\n\n/**\n * Resolve who a form submission would notify, for a site, right now.\n *\n * This exists because the pre-launch guard had no feedback. Its whole state is\n * one Airtable `Status` cell nobody is looking at while testing, the site\n * reports nothing about it, and the only way to learn the answer was to submit\n * a form and read the `resend_message_id` afterwards. On 2026-08-03 an intended\n * flip never landed and a real client received a test lead — an email cannot be\n * recalled. A guard whose state you cannot see is a guard you will eventually\n * assume is on.\n *\n * Every address here comes back out of `resolveRecipients` itself rather than\n * from a second reading of the same rules — for a routed site each branch is\n * probed with a synthetic submission and the results unioned. A copy of the\n * routing logic that drifted would be worse than no answer at all, because it\n * would be a confident wrong one.\n */\nexport function describeNotifyTarget(site: WebsiteRow): NotifyTarget {\n if (site.status !== \"maintenance\") {\n const guarded = resolveRecipients(site, probeSubmission(null));\n return {\n audience: \"operator\",\n to: guarded?.to ?? [],\n cc: [],\n reason: `Status is ${site.status === null ? \"blank\" : `\"${site.status}\"`}, not \"maintenance\" — the pre-launch guard routes every notification to the operator.`,\n };\n }\n\n const routing = site.notifyRouting;\n if (!routing) {\n const direct = resolveRecipients(site, probeSubmission(null));\n if (!direct || direct.to.length === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" but the site has no Point of Contact and no Notify Routing — the lead is stored and no notification is sent.`,\n };\n }\n return {\n audience: \"client\",\n to: direct.to,\n cc: direct.cc,\n reason: `Status is \"maintenance\" — notifications go to the site's Point of Contact.`,\n };\n }\n\n // Probe every declared route value, plus one value that matches none so the\n // `default` (and the POC fall-through behind it) is covered too.\n const to = new Set<string>();\n const cc = new Set<string>();\n const values = [...Object.keys(routing.routes), \"\u0000no-such-route\"];\n for (const value of values) {\n const r = resolveRecipients(site, probeSubmission(JSON.stringify({ [routing.field]: value })));\n r?.to.forEach((a) => to.add(a));\n r?.cc.forEach((a) => cc.add(a));\n }\n if (to.size === 0) {\n return {\n audience: \"nobody\",\n to: [],\n cc: [],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\", but no route, default or Point of Contact resolves to an address.`,\n };\n }\n return {\n audience: \"client\",\n to: [...to],\n cc: [...cc],\n reason: `Status is \"maintenance\" with Notify Routing on \"${routing.field}\" — the recipient depends on that field's value; every address it could reach is listed.`,\n };\n}\n\n/** Humanize an extraFields key for display: \"appointment_date\" → \"Appointment date\". */\nfunction humanizeKey(k: string): string {\n const spaced = k.replace(/[_-]+/g, \" \").trim();\n return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : k;\n}\n\nfunction formatValue(v: unknown): string {\n if (v === null || v === undefined) return \"—\";\n if (typeof v === \"string\") return v;\n if (typeof v === \"number\" || typeof v === \"boolean\") return String(v);\n return JSON.stringify(v);\n}\n\n/** Parse the stored `extraFields` JSON into a plain object — bad JSON or a\n * non-object yields {} (never throws). Shared by the email renderer and routing. */\nfunction parseExtraFields(raw: string | null): Record<string, unknown> {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === \"object\" && !Array.isArray(parsed)) {\n return parsed as Record<string, unknown>;\n }\n } catch {\n /* malformed JSON → no fields */\n }\n return {};\n}\n\nfunction extraFieldRows(raw: string | null): Array<[string, string]> {\n return Object.entries(parseExtraFields(raw))\n .filter(([, v]) => !(typeof v === \"string\" && v.trim() === \"\"))\n .map(([k, v]) => [humanizeKey(k), formatValue(v)] as [string, string]);\n}\n\nfunction fieldsTable(submission: SubmissionRow): string {\n const rows: Array<[string, string]> = [\n [\"Form\", submission.formType],\n [\"Name\", submission.name || \"—\"],\n [\"Email\", submission.email || \"—\"],\n ];\n if (submission.phone) rows.push([\"Phone\", submission.phone]);\n // Site-specific context (the artwork an inquiry is about, the event an rsvp is\n // for, etc.) lives in extraFields — surface it so the recipient sees what the\n // submitter was looking at, not just their name and email.\n rows.push(...extraFieldRows(submission.extraFields));\n if (submission.sourceUrl) rows.push([\"Page\", submission.sourceUrl]);\n if (submission.utm) rows.push([\"UTM\", submission.utm]);\n const body = rows\n .map(([k, v]) => `<tr><td><strong>${escapeHtml(k)}</strong></td><td>${escapeHtml(v)}</td></tr>`)\n .join(\"\");\n const message = submission.message\n ? `<p style=\"white-space:pre-wrap\">${escapeHtml(submission.message)}</p>`\n : \"\";\n return `<table>${body}</table>${message}`;\n}\n\n/** POC notification — the primary email; null when the site has no contact address. */\nexport function buildPocNotification(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n const recipients = resolveRecipients(site, submission);\n if (!recipients || recipients.to.length === 0) return null;\n const input: ResendSendInput = {\n from: `${displayName(site.name)} Forms <${FORMS_FROM}>`,\n to: recipients.to,\n subject: `New ${submission.formType} from ${site.name}`,\n html: `<h2>New ${escapeHtml(submission.formType)} submission — ${escapeHtml(\n site.name,\n )}</h2>${fieldsTable(submission)}`,\n };\n if (recipients.cc.length > 0) input.cc = recipients.cc;\n // Reply straight to the lead.\n if (submission.email) input.replyTo = submission.email;\n return input;\n}\n\n/** Autoresponder to the submitter — null when there's no submitter email. */\nexport function buildAutoresponder(\n site: WebsiteRow,\n submission: SubmissionRow,\n): ResendSendInput | null {\n if (submission.status === \"spam_auto\" || submission.status === \"spam\") return null;\n if (!submission.email) return null;\n const intro = site.copyIntro ?? `Thanks for reaching out to ${site.name}.`;\n const contact = site.copyContact ?? \"We've received your message and will be in touch soon.\";\n const footer = site.copyFooter ?? site.name;\n return {\n from: `${displayName(site.name)} <${FORMS_FROM}>`,\n to: [submission.email],\n replyTo: resolveRecipients(site, submission)?.to[0] ?? FALLBACK_REPLY_TO,\n subject: \"We got your message\",\n html: `<p>${escapeHtml(intro)}</p><p>${escapeHtml(contact)}</p><p>${escapeHtml(footer)}</p>`,\n };\n}\n\nexport type NotifyDeps = {\n send: (input: ResendSendInput) => Promise<{ messageId: string }>;\n};\n\nexport type NotifyOutcome = { status: NotifyStatus; messageId: string | null };\n\n/**\n * Send the POC notification (primary — drives notifyStatus) then the submitter\n * autoresponder (best-effort — logged, never changes the outcome). The submission\n * is already persisted before this runs, so a Resend outage degrades to\n * notifyStatus=\"failed\", never a lost lead.\n */\nexport async function notifySubmission(\n deps: NotifyDeps,\n site: WebsiteRow,\n submission: SubmissionRow,\n): Promise<NotifyOutcome> {\n const poc = buildPocNotification(site, submission);\n let outcome: NotifyOutcome;\n if (!poc) {\n outcome = { status: \"skipped\", messageId: null };\n } else {\n try {\n const { messageId } = await deps.send(poc);\n outcome = { status: \"sent\", messageId };\n } catch (err) {\n console.error(`[submissions] POC notification failed: ${String(err)}`);\n outcome = { status: \"failed\", messageId: null };\n }\n }\n const auto = buildAutoresponder(site, submission);\n if (auto) {\n try {\n await deps.send(auto);\n } catch (err) {\n console.error(`[submissions] autoresponder failed: ${String(err)}`);\n }\n }\n return outcome;\n}\n\n/**\n * Build the ingest `notify` dependency from a Resend send fn — or `null` when the\n * Resend client couldn't even be constructed (e.g. `RESEND_API_KEY` unset). A null\n * send marks the notification `failed` WITHOUT attempting it, so a Resend\n * misconfiguration degrades to a captured-but-unemailed lead rather than aborting\n * ingest and losing it. Mirrors the in-flight failure isolation in notifySubmission.\n */\nexport function makeNotify(\n send: NotifyDeps[\"send\"] | null,\n): (site: WebsiteRow, submission: SubmissionRow) => Promise<NotifyOutcome> {\n return (site, submission) =>\n send\n ? notifySubmission({ send }, site, submission)\n : Promise.resolve({ status: \"failed\", messageId: null });\n}\n","import { openBase, readAirtableConfig } from \"../reports/airtable/client.js\";\nimport type { AirtableBase } from \"../reports/airtable/client.js\";\nimport {\n listWebsites,\n siteSlug,\n updateSiteField,\n type Status,\n type WebsiteRow,\n} from \"../reports/airtable/websites.js\";\nimport { describeNotifyTarget, type NotifyTarget } from \"../forms/notify.js\";\n\n/** The Airtable column the pre-launch guard actually lives in. */\nexport const STATUS_COLUMN = \"Status\";\n\n/** The two ends of the verify flip. Deliberately the ONLY transition this\n * performs: a site in any other status is already guarded (or is deliberately\n * something else, like \"hosting\"), and silently rewriting that would be the\n * same class of unseen change as the incident. */\nexport const LIVE_STATUS: Status = \"maintenance\";\nexport const VERIFY_STATUS: Status = \"launch period\";\n\nexport type FormsNotifyTargetDeps = {\n base?: AirtableBase;\n /** Site slug or the Airtable Websites NAME (both accepted). */\n site: string;\n /** `on` routes notifications to the operator; `off` restores. Omit to read. */\n set?: \"on\" | \"off\";\n /** Status to restore with `--set off`. Required, never inferred. */\n restore?: string;\n};\n\nexport type FormsNotifyTargetResult = {\n site: string;\n status: Status | null;\n target: NotifyTarget;\n /** Present only when a flip was attempted. `confirmed` is the whole point:\n * it comes from RE-READING the row, not from the write call returning. */\n flip?: { from: Status | null; to: Status; confirmed: boolean };\n};\n\nfunction findSite(rows: WebsiteRow[], site: string): WebsiteRow | undefined {\n const wanted = site.trim().toLowerCase();\n return rows.find(\n (r) => siteSlug(r.name) === siteSlug(site) || r.name.trim().toLowerCase() === wanted,\n );\n}\n\n/**\n * Answer \"who would a form submission on this site email?\" — and optionally\n * flip the pre-launch guard, confirming the flip by reading it back.\n *\n * The guard is a single Airtable `Status` cell. Nothing between \"I intended to\n * flip it\" and \"the client received a test lead\" reported the current state, so\n * on 2026-08-03 a flip that never landed sent a real client a test submission.\n * The fix is not a better intention, it is feedback: this reads the row back\n * after every write and refuses to call the flip confirmed on anything less.\n */\nexport async function formsNotifyTarget(\n deps: FormsNotifyTargetDeps,\n): Promise<FormsNotifyTargetResult> {\n const base = deps.base ?? openBase(readAirtableConfig());\n const rows = await listWebsites(base);\n const row = findSite(rows, deps.site);\n if (!row) {\n // The Websites NAME is not the repo slug (\"Sonder\", not \"gallerysonder\"),\n // and that mismatch has cost time before — so name the near misses rather\n // than making the operator go read Airtable to find the spelling.\n const needle = siteSlug(deps.site);\n const near = rows\n .map((r) => r.name)\n .filter((n) => siteSlug(n).includes(needle) || needle.includes(siteSlug(n)));\n throw Object.assign(\n new Error(\n `No Websites row matches '${deps.site}'. The Websites NAME is not always the repo ` +\n `slug (Sonder, not gallerysonder).` +\n (near.length > 0 ? ` Did you mean: ${near.join(\", \")}?` : \"\"),\n ),\n { exitCode: 2 },\n );\n }\n\n if (!deps.set) {\n return { site: row.name, status: row.status, target: describeNotifyTarget(row) };\n }\n\n const to = deps.set === \"on\" ? VERIFY_STATUS : (deps.restore?.trim() as Status | undefined);\n if (deps.set === \"off\" && !to) {\n throw Object.assign(\n new Error(\n `--set off needs --restore <status>: the status to return to is never inferred. ` +\n `Guessing \"${LIVE_STATUS}\" for a site that was \"hosting\" or \"legacy\" would start ` +\n `sending real client notifications — the inverse of the failure this command exists ` +\n `to prevent.`,\n ),\n { exitCode: 2 },\n );\n }\n // Only ever flip a LIVE site into verify mode. A site already outside\n // \"maintenance\" is guarded already, and rewriting its status would destroy a\n // real value nobody asked us to touch.\n if (deps.set === \"on\" && row.status !== LIVE_STATUS) {\n throw Object.assign(\n new Error(\n `${row.name} is \"${row.status ?? \"blank\"}\", not \"${LIVE_STATUS}\" — notifications ` +\n `already go to the operator only, so there is nothing to flip. Leaving the status ` +\n `untouched.`,\n ),\n { exitCode: 2 },\n );\n }\n\n await updateSiteField(base, row.id, STATUS_COLUMN, to!);\n\n // Read it back. The write returning is NOT evidence the field changed.\n const after = findSite(await listWebsites(base), deps.site);\n if (!after) {\n throw Object.assign(new Error(`${row.name} vanished from Websites during the flip.`), {\n exitCode: 1,\n });\n }\n return {\n site: after.name,\n status: after.status,\n target: describeNotifyTarget(after),\n flip: { from: row.status, to: to!, confirmed: after.status === to },\n };\n}\n","import {\n formsNotifyTarget,\n LIVE_STATUS,\n VERIFY_STATUS,\n type FormsNotifyTargetResult,\n} from \"../../recipes/forms-notify-target.js\";\n\nexport type FormsNotifyTargetCommandOptions = {\n set?: string;\n restore?: string;\n cwd?: string;\n};\n\nconst AUDIENCE_LABEL = {\n client: \"THE CLIENT\",\n operator: \"OPERATOR ONLY\",\n nobody: \"NOBODY\",\n} as const;\n\n/** The line that decides whether it is safe to test-submit. Deliberately the\n * loudest thing on screen: the incident happened because the answer was\n * invisible, not because it was subtle. */\nexport function formatNotifyTarget(r: FormsNotifyTargetResult): string {\n const lines: string[] = [];\n if (r.flip) {\n const arrow = `${r.flip.from ?? \"blank\"} → ${r.flip.to}`;\n lines.push(\n r.flip.confirmed\n ? `${r.site} Status: ${arrow} ✓ confirmed by read-back`\n : `${r.site} Status: ${arrow} ✗ NOT CONFIRMED — the row still reads \"${r.status ?? \"blank\"}\". Nothing was verified; do not test-submit.`,\n );\n } else {\n lines.push(`${r.site} Status: ${r.status ?? \"blank\"}`);\n }\n\n lines.push(`A submission right now would notify: ${AUDIENCE_LABEL[r.target.audience]}`);\n if (r.target.to.length > 0) lines.push(` to: ${r.target.to.join(\", \")}`);\n if (r.target.cc.length > 0) lines.push(` cc: ${r.target.cc.join(\", \")}`);\n lines.push(` ${r.target.reason}`);\n\n if (r.target.audience === \"client\") {\n lines.push(\n \"\",\n `⚠️ A test submission WILL email the client, and email cannot be recalled.`,\n ` Route it to yourself first: reddoor forms-notify-target ${r.site} --set on`,\n );\n }\n if (r.flip?.confirmed && r.flip.to === VERIFY_STATUS) {\n lines.push(\n \"\",\n `Safe to test. When you are done, restore it:`,\n ` reddoor forms-notify-target ${r.site} --set off --restore ${r.flip.from ?? LIVE_STATUS}`,\n );\n }\n return lines.join(\"\\n\");\n}\n\n/**\n * `forms-notify-target <site>` — show who a form submission would email, and\n * optionally flip the pre-launch guard with a read-back confirmation.\n *\n * Read-only by default: answering the question must never be riskier than not\n * asking it.\n */\nexport async function runFormsNotifyTargetCommand(\n site: string | undefined,\n opts: FormsNotifyTargetCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (!site?.trim()) {\n return { output: \"forms-notify-target requires <site> (slug or Airtable name)\", code: 2 };\n }\n const set = opts.set?.trim().toLowerCase();\n if (set !== undefined && set !== \"on\" && set !== \"off\") {\n return { output: `--set must be 'on' or 'off' (got '${opts.set}')`, code: 2 };\n }\n try {\n const result = await formsNotifyTarget({\n site: site.trim(),\n ...(set ? { set: set as \"on\" | \"off\" } : {}),\n ...(opts.restore ? { restore: opts.restore } : {}),\n });\n // An unconfirmed flip must not exit 0: a script (or a person skimming) that\n // reads exit status would otherwise take \"I flipped it\" on faith, which is\n // exactly the assumption that sent a client a test lead.\n return {\n output: formatNotifyTarget(result),\n code: result.flip && !result.flip.confirmed ? 1 : 0,\n };\n } catch (err) {\n const e = err as { message?: string; exitCode?: number };\n return { output: e.message ?? String(err), code: e.exitCode ?? 1 };\n }\n}\n"],"mappings":"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","names":[]}
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
} from "./chunk-5L3FPQIJ.js";
|
|
12
12
|
import {
|
|
13
13
|
makeGitHub
|
|
14
|
-
} from "./chunk-
|
|
14
|
+
} from "./chunk-YAMRVXOD.js";
|
|
15
15
|
import {
|
|
16
16
|
recordFleetEventsBestEffort
|
|
17
17
|
} from "./chunk-QG6OSL4M.js";
|
|
@@ -134,4 +134,4 @@ export {
|
|
|
134
134
|
githubSignalsExitCode,
|
|
135
135
|
runGitHubSignalsCommand
|
|
136
136
|
};
|
|
137
|
-
//# sourceMappingURL=github-signals-
|
|
137
|
+
//# sourceMappingURL=github-signals-2BFMZLPV.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -110,13 +110,16 @@ type VerifyDeps = {
|
|
|
110
110
|
/** Outcome of driving one site's contact form. `formPresent:false` ⇒ n/a
|
|
111
111
|
* (persisted). `testModeUndeclared` ⇒ the site's /health does not declare
|
|
112
112
|
* `forms.testMode`, so the probe refused to submit — a plain skip, prior
|
|
113
|
-
* verdict preserved.
|
|
113
|
+
* verdict preserved. `elapsedMs` times the submit itself (click → success
|
|
114
|
+
* banner) and feeds the budget-headroom check; absent means "not measured",
|
|
115
|
+
* which never manufactures a verdict. */
|
|
114
116
|
type FormSubmitOutcome = {
|
|
115
117
|
formPresent: false;
|
|
116
118
|
} | {
|
|
117
119
|
formPresent: true;
|
|
118
120
|
success: boolean;
|
|
119
121
|
detail?: string;
|
|
122
|
+
elapsedMs?: number;
|
|
120
123
|
} | {
|
|
121
124
|
testModeUndeclared: true;
|
|
122
125
|
};
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import "./chunk-
|
|
1
|
+
import "./chunk-KKIKJWM2.js";
|
|
2
2
|
import {
|
|
3
3
|
draftReportForSite
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-6M2IXJ3E.js";
|
|
5
|
+
import "./chunk-62VF32IC.js";
|
|
6
6
|
import {
|
|
7
7
|
domainFromUrl,
|
|
8
8
|
generateHeaderImage,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
} from "./chunk-37S6L6G4.js";
|
|
11
11
|
import {
|
|
12
12
|
approveBlockers
|
|
13
|
-
} from "./chunk-
|
|
13
|
+
} from "./chunk-R5KAXGSP.js";
|
|
14
14
|
import {
|
|
15
15
|
findDueReports
|
|
16
16
|
} from "./chunk-D4FVAIEJ.js";
|
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
DEFAULT_INIT_STEPS,
|
|
23
23
|
a11yFixturesPage,
|
|
24
24
|
init
|
|
25
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-TEOENEJH.js";
|
|
26
26
|
import {
|
|
27
27
|
convertToPnpm
|
|
28
28
|
} from "./chunk-MGJ72BA4.js";
|
|
@@ -42,28 +42,27 @@ import {
|
|
|
42
42
|
parseAddresses,
|
|
43
43
|
sendApprovedReports,
|
|
44
44
|
withGlobalCc
|
|
45
|
-
} from "./chunk-
|
|
45
|
+
} from "./chunk-42A4H6SF.js";
|
|
46
46
|
import "./chunk-GECESMVS.js";
|
|
47
47
|
import {
|
|
48
48
|
BLURRED_CID,
|
|
49
49
|
CHECK_CID,
|
|
50
50
|
checklistFor,
|
|
51
|
-
escapeHtml,
|
|
52
51
|
gatingFields,
|
|
53
52
|
isHealthGateClear,
|
|
54
53
|
isPendingApproval,
|
|
55
54
|
loadBundledImages,
|
|
56
|
-
renderReportHtml
|
|
57
|
-
|
|
58
|
-
} from "./chunk-MNE374CZ.js";
|
|
55
|
+
renderReportHtml
|
|
56
|
+
} from "./chunk-6QRLRT3P.js";
|
|
59
57
|
import "./chunk-MPBXOJ3N.js";
|
|
58
|
+
import {
|
|
59
|
+
escapeHtml,
|
|
60
|
+
safeUrl
|
|
61
|
+
} from "./chunk-SEEA6RXF.js";
|
|
60
62
|
import "./chunk-WEKL2HYJ.js";
|
|
61
63
|
import {
|
|
62
64
|
bumpDeps
|
|
63
65
|
} from "./chunk-ATJ6ZA2V.js";
|
|
64
|
-
import {
|
|
65
|
-
isLeadFormType
|
|
66
|
-
} from "./chunk-2OP4JKIQ.js";
|
|
67
66
|
import {
|
|
68
67
|
syncConfigs
|
|
69
68
|
} from "./chunk-Q276MZ7E.js";
|
|
@@ -79,9 +78,9 @@ import {
|
|
|
79
78
|
runAudits,
|
|
80
79
|
runAuditsAcross,
|
|
81
80
|
securityAudit
|
|
82
|
-
} from "./chunk-
|
|
81
|
+
} from "./chunk-JMAFXEQE.js";
|
|
83
82
|
import "./chunk-GASBX52O.js";
|
|
84
|
-
import "./chunk-
|
|
83
|
+
import "./chunk-YAMRVXOD.js";
|
|
85
84
|
import "./chunk-LKGVSM2O.js";
|
|
86
85
|
import "./chunk-QG6OSL4M.js";
|
|
87
86
|
import "./chunk-DC5H7KEQ.js";
|
|
@@ -91,6 +90,10 @@ import {
|
|
|
91
90
|
localPath
|
|
92
91
|
} from "./chunk-FDIFTIXC.js";
|
|
93
92
|
import "./chunk-HVOOCK6L.js";
|
|
93
|
+
import "./chunk-A3WDWNN5.js";
|
|
94
|
+
import {
|
|
95
|
+
isLeadFormType
|
|
96
|
+
} from "./chunk-2OP4JKIQ.js";
|
|
94
97
|
import "./chunk-XXTZBPUY.js";
|
|
95
98
|
import "./chunk-XTK5VIQB.js";
|
|
96
99
|
import {
|
|
@@ -103,8 +106,8 @@ import {
|
|
|
103
106
|
} from "./chunk-GXVX4HUH.js";
|
|
104
107
|
import "./chunk-HN6I2XWI.js";
|
|
105
108
|
import "./chunk-6VAL7XJT.js";
|
|
106
|
-
import "./chunk-
|
|
107
|
-
import "./chunk-
|
|
109
|
+
import "./chunk-NMAWXBJA.js";
|
|
110
|
+
import "./chunk-N4MOEF32.js";
|
|
108
111
|
|
|
109
112
|
// src/recipes/index.ts
|
|
110
113
|
var ALL_RECIPE_NAMES = [
|