create-ailk 0.23.0 → 0.24.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/README.md +27 -14
- package/dist/add-module.js +6 -1
- package/dist/add-route.js +7 -1
- package/dist/derive-architecture.js +17 -0
- package/dist/lib/scaffold-render-sweep.d.ts +33 -0
- package/dist/lib/scaffold-render-sweep.js +45 -0
- package/dist/module-architecture.js +17 -0
- package/dist/sync-routes.d.ts +30 -4
- package/dist/sync-routes.js +271 -18
- package/package.json +3 -3
- package/templates/.env.example +4 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/apps/api/.env.example +7 -0
- package/templates/apps/api/CLAUDE.md +25 -11
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +3 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +333 -0
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
- package/templates/apps/api/src/routes/content/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- package/templates/apps/api/src/routes/content/search.ts +16 -45
- package/templates/apps/api/src/routes/leads/index.ts +364 -298
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
- package/templates/apps/api/src/server.ts +25 -0
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
- package/templates/apps/api/src/services/offerings/connection.ts +83 -3
- package/templates/apps/api/src/services/offerings/index.ts +6 -6
- package/templates/apps/api/src/services/person.ts +23 -0
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/mcp/CLAUDE.md +11 -5
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- package/templates/apps/mcp/src/tools/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/web/CLAUDE.md +12 -3
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
- package/templates/apps/web/lib/magnet-routes.ts +3 -1
- package/templates/apps/web/lib/newsletter.actions.ts +9 -6
- package/templates/database/CHANGELOG.md +20 -0
- package/templates/database/__tests__/seed.test.ts +33 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +27 -0
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
|
@@ -71,10 +71,30 @@
|
|
|
71
71
|
* direct caller can still send `consent: true`; the gate makes acceptance an
|
|
72
72
|
* explicit, recorded assertion, not proof that a person read the terms.
|
|
73
73
|
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
74
|
+
* THE BODY GATE (#7171, #7172, #7173 — the operator's decision of
|
|
75
|
+
* 2026-10-19: the MCP tool adopts the API, arguments and results). Each of
|
|
76
|
+
* the three writes is its own registration, and each parses `request.body`
|
|
77
|
+
* with one exported `@working-theory/validation` schema —
|
|
78
|
+
* `leadSubmitBodySchema`, `leadSubscribeBodySchema`, `leadCallbackBodySchema`
|
|
79
|
+
* — answering `400 invalid_input` (the first issue's message) when the body
|
|
80
|
+
* is not admitted; `capture_lead`, `subscribe_newsletter` and
|
|
81
|
+
* `request_callback` take exactly those exports and return the 201 below
|
|
82
|
+
* unchanged, so the parity guard holds the three pairs as `twin`s. The
|
|
83
|
+
* schemas are plain objects, never `.strict()`: every body a site form sends
|
|
84
|
+
* today — `siteId` beside the fields, the StepFlow envelope with `email`
|
|
85
|
+
* inside `payload`, no `payload` at all — still passes, an unknown key is
|
|
86
|
+
* stripped from the parsed copy and never refused. The parsed value is a
|
|
87
|
+
* gate only: `captureLead` reads the RAW `request.body`, so what `routeLead`
|
|
88
|
+
* validates and persists is byte-identical to before (#5732 — the payload
|
|
89
|
+
* walk sees the object that is stored). The one new refusal beyond the
|
|
90
|
+
* schema's own is a malformed or `null` body, which threw before.
|
|
91
|
+
*
|
|
92
|
+
* API↔MCP PARITY for `consent` (#8730): `leadSubmitBodySchema` declares it
|
|
93
|
+
* as an optional boolean, so the guard sees the same argument on both sides.
|
|
94
|
+
* The REQUIREMENT — `true` on a consent-bearing source — is the handler's
|
|
95
|
+
* own check below, which the guard does not model; `capture_lead` sends the
|
|
96
|
+
* agent's own `source`, so an agent naming a consent source needs
|
|
97
|
+
* `consent: true` exactly as a form does.
|
|
78
98
|
*
|
|
79
99
|
* A MAGNET'S COMPLETION (#8757 D6). After a `flow:<slug>` submit is written
|
|
80
100
|
* to the database, `services/magnet-completion.ts` reads that LIVE magnet's
|
|
@@ -87,9 +107,10 @@
|
|
|
87
107
|
* `lead`, a draft or missing magnet, or a caller with nowhere to sign in
|
|
88
108
|
* leaves the 201 as it was. A failed read or send never changes the 201,
|
|
89
109
|
* and the completion read has a time budget (`MAGNET_COMPLETION_BUDGET_MS`).
|
|
90
|
-
* `next` is reachable only through a `flow:` source
|
|
91
|
-
* `
|
|
92
|
-
*
|
|
110
|
+
* `next` is reachable only through a `flow:` source, which the parity guard's
|
|
111
|
+
* own argument fixture for `capture_lead` never sends, so the guard's
|
|
112
|
+
* observation never carries it and `leadWriteResultSchema` does not declare
|
|
113
|
+
* it (NFR5).
|
|
93
114
|
*
|
|
94
115
|
* A CHECKOUT'S ACCOUNT LINK (#8757 D14). A checkout fulfilment's submit
|
|
95
116
|
* (`source: "web"`, `payload.page: "checkout"`) carries the caller's account
|
|
@@ -101,20 +122,23 @@
|
|
|
101
122
|
*
|
|
102
123
|
* API↔MCP PARITY for the header (#7869 D9, roadmap §12 Q5): NO guard entry.
|
|
103
124
|
* The guard binds a route's arguments to the validation export its handler
|
|
104
|
-
* `.safeParse`s on `request.params|query|body
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
125
|
+
* `.safeParse`s on `request.params|query|body` — here `leadSubmitBodySchema`
|
|
126
|
+
* — and models no header; the header check below is a regex test, not such a
|
|
127
|
+
* parse, so the key is outside the compared contract. The 201 body's field
|
|
128
|
+
* set is unchanged too, and `capture_lead` sends no header. If the guard
|
|
129
|
+
* ever models headers, this becomes an API-side `arg-only-in`.
|
|
109
130
|
*/
|
|
110
131
|
|
|
111
132
|
import { track } from "@working-theory/analytics/server";
|
|
112
133
|
import {
|
|
113
134
|
CONTACT_FORM_TERMS_STATEMENT,
|
|
114
135
|
isConsentLeadSource,
|
|
136
|
+
leadCallbackBodySchema,
|
|
137
|
+
leadSubmitBodySchema,
|
|
138
|
+
leadSubscribeBodySchema,
|
|
115
139
|
type LeadSubmitNext,
|
|
116
140
|
} from "@working-theory/validation";
|
|
117
|
-
import type { FastifyPluginAsync } from "fastify";
|
|
141
|
+
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
|
|
118
142
|
|
|
119
143
|
import { acceptsKind, resolveLeadDelivery } from "../../lib/lead-delivery.js";
|
|
120
144
|
import { currentSiteId, resolveSiteWorkspaceId } from "../../lib/site.js";
|
|
@@ -243,313 +267,355 @@ function isCheckoutSubmit(body: Record<string, unknown>): boolean {
|
|
|
243
267
|
);
|
|
244
268
|
}
|
|
245
269
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
270
|
+
/**
|
|
271
|
+
* The refusal a body the route's own export does not admit gets (#7171,
|
|
272
|
+
* #7172, #7173): the shape and the first-issue message `routeLead`'s
|
|
273
|
+
* `invalid_input` branch already answers. Logs nothing.
|
|
274
|
+
*/
|
|
275
|
+
function refuseBody(
|
|
276
|
+
reply: FastifyReply,
|
|
277
|
+
issues: readonly { message: string }[],
|
|
278
|
+
) {
|
|
279
|
+
return reply.status(400).send({
|
|
280
|
+
error: "invalid_input",
|
|
281
|
+
message: issues[0]?.message ?? "invalid input",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
250
284
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
// the value; neither branches routing, persistence, or dedup on it.
|
|
267
|
-
// The best-effort/record-only posture is judged sufficient — the same
|
|
268
|
-
// trade-off server.ts's own trustProxy comment already accepts. The
|
|
269
|
-
// private @working-theory/lead-routing package (not present in this
|
|
270
|
-
// repo, not inspectable) is the one consumer this trace could not
|
|
271
|
-
// complete; see docs/specs/5112-leads-ip-trace-spec.md §8.
|
|
272
|
-
const sourceMeta: SourceMeta = {
|
|
273
|
-
ip: request.ip,
|
|
274
|
-
userAgent: request.headers["user-agent"],
|
|
275
|
-
receivedAt: new Date().toISOString(),
|
|
276
|
-
};
|
|
285
|
+
/**
|
|
286
|
+
* The shared capture, after a kind's own gates: tenant resolution, the
|
|
287
|
+
* idempotency key, `buildInput` over the RAW body, `routeLead`, the confirm
|
|
288
|
+
* link, `next`, `track` and the 201. Reads `request.body` itself — never the
|
|
289
|
+
* body export's parsed copy — so what `routeLead` validates and persists is
|
|
290
|
+
* the object the client sent (the #5732 property: `boundedLeadPayload` walks
|
|
291
|
+
* exactly what is persisted, an own `__proto__` key included, where a
|
|
292
|
+
* `z.record` copy would have dropped it).
|
|
293
|
+
*/
|
|
294
|
+
async function captureLead(
|
|
295
|
+
kind: LeadKind,
|
|
296
|
+
request: FastifyRequest,
|
|
297
|
+
reply: FastifyReply,
|
|
298
|
+
) {
|
|
299
|
+
const body = request.body as Record<string, unknown>;
|
|
277
300
|
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
301
|
+
// request.ip became attacker-influenceable when trustProxy: true went
|
|
302
|
+
// instance-wide (server.ts, #4730/#4778) — leads/index.ts was never
|
|
303
|
+
// itself touched by that PR. Traced every consumer of this value
|
|
304
|
+
// (#5112, DEF-004): lead-routing.ts's structured log field and its
|
|
305
|
+
// opt-in (LEAD_CAPTURE_INCLUDE_IP) persisted metadata.ip both RECORD
|
|
306
|
+
// the value; neither branches routing, persistence, or dedup on it.
|
|
307
|
+
// The best-effort/record-only posture is judged sufficient — the same
|
|
308
|
+
// trade-off server.ts's own trustProxy comment already accepts. The
|
|
309
|
+
// private @working-theory/lead-routing package (not present in this
|
|
310
|
+
// repo, not inspectable) is the one consumer this trace could not
|
|
311
|
+
// complete; see docs/specs/5112-leads-ip-trace-spec.md §8.
|
|
312
|
+
const sourceMeta: SourceMeta = {
|
|
313
|
+
ip: request.ip,
|
|
314
|
+
userAgent: request.headers["user-agent"],
|
|
315
|
+
receivedAt: new Date().toISOString(),
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
// #7532 D4 — a registered spoke resolves first, to its own workspace; a
|
|
319
|
+
// refused key never falls through to the alias below. A keyless lead
|
|
320
|
+
// whose hub-status read fails on a cold start proceeds as on a non-hub,
|
|
321
|
+
// so routeLead's outage fallback captures it (#7633 amendment).
|
|
322
|
+
const caller = await resolveCallerSite(request, {
|
|
323
|
+
keylessOnRegistryFailure: "anonymous",
|
|
324
|
+
});
|
|
325
|
+
if (caller.kind === "refused") {
|
|
326
|
+
return reply.status(503).send({
|
|
327
|
+
error: "tenant_unresolved",
|
|
328
|
+
message: "This site is not configured to accept submissions.",
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// #6203 — resolve the tenant SERVER-SIDE before any write is
|
|
333
|
+
// attempted. Two branches, deliberately different:
|
|
334
|
+
//
|
|
335
|
+
// - No DATABASE_URL: the zero-config OSS file-fallback deployment.
|
|
336
|
+
// There is no `workspaces` table to resolve against (and
|
|
337
|
+
// `assertSiteAliasResolves()` already skips its own boot check for
|
|
338
|
+
// exactly this reason), so the "resolved" value here is the site
|
|
339
|
+
// ALIAS itself (`currentSiteId()`) — the same value the file
|
|
340
|
+
// fallback would have carried before this unit. Never a rejection:
|
|
341
|
+
// that would break lead capture for every zero-config scaffold.
|
|
342
|
+
// - DATABASE_URL set: resolve via `resolveSiteWorkspaceId()`, the
|
|
343
|
+
// SAME alias -> workspace resolution `currentSiteId()` already
|
|
344
|
+
// drives everywhere else in this app. `null` here is a genuine
|
|
345
|
+
// misconfiguration `build()` already refuses to boot past
|
|
346
|
+
// (`assertSiteAliasResolves()`) — reaching it at request time is
|
|
347
|
+
// the residual case that startup check cannot catch (a workspace
|
|
348
|
+
// deleted after boot, a transient read), and it fails closed the
|
|
349
|
+
// same way: reject, never guess (spec AC-4).
|
|
350
|
+
let workspaceId: string;
|
|
351
|
+
if (caller.kind === "spoke") {
|
|
352
|
+
workspaceId = caller.workspaceId;
|
|
353
|
+
sourceMeta.viaSpoke = true;
|
|
354
|
+
} else if (!process.env.DATABASE_URL) {
|
|
355
|
+
workspaceId = currentSiteId();
|
|
356
|
+
} else {
|
|
357
|
+
const resolved = await resolveSiteWorkspaceId();
|
|
358
|
+
if (resolved === null) {
|
|
359
|
+
request.log.error(
|
|
360
|
+
{ kind },
|
|
361
|
+
"lead capture: DATABASE_URL is set but the site alias resolves to no workspace",
|
|
362
|
+
);
|
|
363
|
+
return reply.status(503).send({
|
|
364
|
+
error: "tenant_unresolved",
|
|
365
|
+
message: "This site is not configured to accept submissions.",
|
|
284
366
|
});
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
367
|
+
}
|
|
368
|
+
workspaceId = resolved;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// #7869 D1 — after the tenant gate, before any read or write. Node
|
|
372
|
+
// lower-cases header names, and joins a duplicated header with ", ",
|
|
373
|
+
// which the pattern refuses.
|
|
374
|
+
let idempotencyKey: string | undefined;
|
|
375
|
+
if (kind === "submit") {
|
|
376
|
+
const rawKey = request.headers["idempotency-key"];
|
|
377
|
+
if (rawKey !== undefined) {
|
|
378
|
+
if (
|
|
379
|
+
typeof rawKey !== "string" ||
|
|
380
|
+
!IDEMPOTENCY_KEY_PATTERN.test(rawKey)
|
|
381
|
+
) {
|
|
382
|
+
return reply.status(400).send({
|
|
383
|
+
error: "invalid_input",
|
|
384
|
+
message:
|
|
385
|
+
"Idempotency-Key must be 16 to 255 visible ASCII characters.",
|
|
289
386
|
});
|
|
290
387
|
}
|
|
388
|
+
idempotencyKey = rawKey;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
291
391
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
// - No DATABASE_URL: the zero-config OSS file-fallback deployment.
|
|
296
|
-
// There is no `workspaces` table to resolve against (and
|
|
297
|
-
// `assertSiteAliasResolves()` already skips its own boot check for
|
|
298
|
-
// exactly this reason), so the "resolved" value here is the site
|
|
299
|
-
// ALIAS itself (`currentSiteId()`) — the same value the file
|
|
300
|
-
// fallback would have carried before this unit. Never a rejection:
|
|
301
|
-
// that would break lead capture for every zero-config scaffold.
|
|
302
|
-
// - DATABASE_URL set: resolve via `resolveSiteWorkspaceId()`, the
|
|
303
|
-
// SAME alias -> workspace resolution `currentSiteId()` already
|
|
304
|
-
// drives everywhere else in this app. `null` here is a genuine
|
|
305
|
-
// misconfiguration `build()` already refuses to boot past
|
|
306
|
-
// (`assertSiteAliasResolves()`) — reaching it at request time is
|
|
307
|
-
// the residual case that startup check cannot catch (a workspace
|
|
308
|
-
// deleted after boot, a transient read), and it fails closed the
|
|
309
|
-
// same way: reject, never guess (spec AC-4).
|
|
310
|
-
let workspaceId: string;
|
|
311
|
-
if (caller.kind === "spoke") {
|
|
312
|
-
workspaceId = caller.workspaceId;
|
|
313
|
-
sourceMeta.viaSpoke = true;
|
|
314
|
-
} else if (!process.env.DATABASE_URL) {
|
|
315
|
-
workspaceId = currentSiteId();
|
|
316
|
-
} else {
|
|
317
|
-
const resolved = await resolveSiteWorkspaceId();
|
|
318
|
-
if (resolved === null) {
|
|
319
|
-
request.log.error(
|
|
320
|
-
{ kind },
|
|
321
|
-
"lead capture: DATABASE_URL is set but the site alias resolves to no workspace",
|
|
322
|
-
);
|
|
323
|
-
return reply.status(503).send({
|
|
324
|
-
error: "tenant_unresolved",
|
|
325
|
-
message: "This site is not configured to accept submissions.",
|
|
326
|
-
});
|
|
327
|
-
}
|
|
328
|
-
workspaceId = resolved;
|
|
329
|
-
}
|
|
392
|
+
// #8185 D3 — a spoke's same-origin relay marks every forwarded call.
|
|
393
|
+
// The mark can only remove the spoke-checkout marker, never grant it.
|
|
394
|
+
sourceMeta.relayed = request.headers["x-ailk-relayed"] !== undefined;
|
|
330
395
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
) {
|
|
342
|
-
return reply.status(400).send({
|
|
343
|
-
error: "invalid_input",
|
|
344
|
-
message:
|
|
345
|
-
"Idempotency-Key must be 16 to 255 visible ASCII characters.",
|
|
346
|
-
});
|
|
347
|
-
}
|
|
348
|
-
idempotencyKey = rawKey;
|
|
349
|
-
}
|
|
350
|
-
}
|
|
396
|
+
// #8438 D11 — the cookie banner's visitor id, forwarded by the site's
|
|
397
|
+
// own server from its first-party cookie. Only a v4 UUID counts; the
|
|
398
|
+
// header is never echoed or logged. It links the visitor to the
|
|
399
|
+
// lead's Person after the write, and changes nothing about the answer.
|
|
400
|
+
const consentVisitorId = consentVisitorFromHeader(
|
|
401
|
+
request.headers[CONSENT_VISITOR_HEADER],
|
|
402
|
+
);
|
|
403
|
+
if (consentVisitorId !== undefined) {
|
|
404
|
+
sourceMeta.consentVisitorId = consentVisitorId;
|
|
405
|
+
}
|
|
351
406
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
407
|
+
// #8757 D14 — where a buyer's account lives, for the purchase email.
|
|
408
|
+
let accountOrigin: string | undefined;
|
|
409
|
+
if (kind === "submit" && isCheckoutSubmit(body)) {
|
|
410
|
+
const target = await accountTargetFor(caller).catch(() => null);
|
|
411
|
+
accountOrigin = target?.origin;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const result = await routeLead(
|
|
415
|
+
buildInput(kind, body, workspaceId, sourceMeta.receivedAt),
|
|
416
|
+
sourceMeta,
|
|
417
|
+
{
|
|
418
|
+
...(idempotencyKey !== undefined && { idempotencyKey }),
|
|
419
|
+
...(accountOrigin !== undefined && { accountOrigin }),
|
|
420
|
+
},
|
|
421
|
+
);
|
|
355
422
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
|
|
361
|
-
|
|
423
|
+
if (!result.ok) {
|
|
424
|
+
if (result.error.kind === "idempotency_conflict") {
|
|
425
|
+
// #7869 D6 — the operator's signal that a keyed lead was refused.
|
|
426
|
+
// Never the key, the email or the payload.
|
|
427
|
+
request.log.warn(
|
|
428
|
+
{ kind, siteId: workspaceId, outcome: "idempotency_conflict" },
|
|
429
|
+
"lead idempotency_conflict",
|
|
362
430
|
);
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
431
|
+
return reply.status(422).send({
|
|
432
|
+
error: "idempotency_key_reused",
|
|
433
|
+
message: "This Idempotency-Key was used for a different lead.",
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
if (result.error.kind === "invalid_input") {
|
|
437
|
+
return reply
|
|
438
|
+
.status(400)
|
|
439
|
+
.send({ error: "invalid_input", message: result.error.message });
|
|
440
|
+
}
|
|
441
|
+
if (result.error.kind === "persist_failed") {
|
|
442
|
+
// Do not surface internal error detail — log it server-side only
|
|
443
|
+
request.log.error(
|
|
444
|
+
{ kind, siteId: workspaceId, error: result.error.message },
|
|
445
|
+
"lead persist_failed",
|
|
446
|
+
);
|
|
447
|
+
return reply.status(503).send({ error: "persist_failed" });
|
|
448
|
+
}
|
|
449
|
+
if (result.error.kind === "delivery_failed") {
|
|
450
|
+
// Email was the delivery and the provider did not accept it —
|
|
451
|
+
// never reported as sent (#6730 D5). Detail stays server-side.
|
|
452
|
+
// `{ kind, siteId }` only (spec D5): provider error text can echo
|
|
453
|
+
// an address.
|
|
454
|
+
request.log.error({ kind, siteId: workspaceId }, "lead delivery_failed");
|
|
455
|
+
return reply.status(503).send({
|
|
456
|
+
error: "delivery_failed",
|
|
457
|
+
message: "The message could not be delivered.",
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
// config_missing — this deployment has nowhere to put this kind of
|
|
461
|
+
// lead (#6730 D1: production with neither a database nor the email
|
|
462
|
+
// vars, or a newsletter signup with no database).
|
|
463
|
+
return reply.status(503).send({
|
|
464
|
+
error: "lead_capture_not_configured",
|
|
465
|
+
message: "Lead capture is not configured.",
|
|
466
|
+
});
|
|
467
|
+
}
|
|
366
468
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
469
|
+
// PII-safe structured log: only routing metadata, no email/phone/name/payload
|
|
470
|
+
const emailKind =
|
|
471
|
+
"skipped" in result.email
|
|
472
|
+
? "skipped"
|
|
473
|
+
: result.email.ok
|
|
474
|
+
? "ok"
|
|
475
|
+
: "error";
|
|
476
|
+
request.log.info(
|
|
477
|
+
{
|
|
478
|
+
siteId: workspaceId,
|
|
479
|
+
kind,
|
|
480
|
+
ip: sourceMeta.ip,
|
|
481
|
+
persisted: result.persisted,
|
|
482
|
+
emailKind,
|
|
483
|
+
replayed: result.replayed === true,
|
|
484
|
+
},
|
|
485
|
+
"lead routed",
|
|
486
|
+
);
|
|
373
487
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
488
|
+
// Request-and-confirm (#5970): mint the tenant's one-time confirm
|
|
489
|
+
// credential and email them the link. Fire-and-forget and `void`-
|
|
490
|
+
// returning, so the 201 below stays independent of both Resend and that
|
|
491
|
+
// write — the request is already persisted by this point, and a send
|
|
492
|
+
// that fails must never turn a captured lead into an error.
|
|
493
|
+
//
|
|
494
|
+
// Callback only: `submit` and `subscribe` have no confirm step. The
|
|
495
|
+
// service itself no-ops (with a logged reason) when the feature is not
|
|
496
|
+
// configured, which is the default.
|
|
497
|
+
// `result.persisted === "db"` for the same reason `lead-routing.ts`
|
|
498
|
+
// consults it (#5970 security review round 3, M1): the credential is
|
|
499
|
+
// minted by an update on the `callbacks` row, so a FILE-FALLBACK id
|
|
500
|
+
// would match nothing and the operator would be left with no email at
|
|
501
|
+
// all. On that path the generic notification is sent instead, which is
|
|
502
|
+
// what keeps "exactly one" true in both directions.
|
|
503
|
+
// A spoke's callback never takes this path (#7532 D4, SEC-7532-2): the
|
|
504
|
+
// link is minted on the deployment's one Stripe account.
|
|
505
|
+
if (
|
|
506
|
+
kind === "callback" &&
|
|
507
|
+
result.persisted === "db" &&
|
|
508
|
+
caller.kind !== "spoke"
|
|
509
|
+
) {
|
|
510
|
+
const callbackInput = buildInput(
|
|
511
|
+
kind,
|
|
512
|
+
body,
|
|
513
|
+
workspaceId,
|
|
514
|
+
sourceMeta.receivedAt,
|
|
515
|
+
) as Extract<RouteLeadInput, { kind: "callback" }>;
|
|
516
|
+
sendRequestConfirmLink(result.id, {
|
|
517
|
+
name: callbackInput.name,
|
|
518
|
+
phone: callbackInput.phone,
|
|
519
|
+
...(callbackInput.preferredTime !== undefined && {
|
|
520
|
+
preferredTime: callbackInput.preferredTime,
|
|
521
|
+
}),
|
|
522
|
+
});
|
|
523
|
+
}
|
|
382
524
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
if (
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
error: "delivery_failed",
|
|
417
|
-
message: "The message could not be delivered.",
|
|
525
|
+
// #8757 D6 — a written `flow:<slug>` submit answers its magnet's
|
|
526
|
+
// completion. A read that fails, or outlasts its budget, leaves the 201
|
|
527
|
+
// as it was: the lead is saved.
|
|
528
|
+
let next: LeadSubmitNext | undefined;
|
|
529
|
+
const source = body["source"];
|
|
530
|
+
if (
|
|
531
|
+
kind === "submit" &&
|
|
532
|
+
result.persisted === "db" &&
|
|
533
|
+
typeof source === "string" &&
|
|
534
|
+
source.startsWith("flow:")
|
|
535
|
+
) {
|
|
536
|
+
const slug = source.slice("flow:".length);
|
|
537
|
+
try {
|
|
538
|
+
const completion = await magnetCompletionWithinBudget(workspaceId, slug, () =>
|
|
539
|
+
accountTargetFor(caller),
|
|
540
|
+
);
|
|
541
|
+
if (completion !== null) {
|
|
542
|
+
next = completion.next;
|
|
543
|
+
if (completion.next.completion === "invite" && !result.replayed) {
|
|
544
|
+
const submitInput = buildInput(
|
|
545
|
+
kind,
|
|
546
|
+
body,
|
|
547
|
+
workspaceId,
|
|
548
|
+
sourceMeta.receivedAt,
|
|
549
|
+
) as Extract<RouteLeadInput, { kind: "submit" }>;
|
|
550
|
+
const locale = submitInput.payload["locale"];
|
|
551
|
+
void sendInviteConfirmation({
|
|
552
|
+
workspaceId,
|
|
553
|
+
slug,
|
|
554
|
+
email: submitInput.email,
|
|
555
|
+
locale: typeof locale === "string" ? locale : undefined,
|
|
556
|
+
completion,
|
|
557
|
+
now: new Date(),
|
|
418
558
|
});
|
|
419
559
|
}
|
|
420
|
-
// config_missing — this deployment has nowhere to put this kind of
|
|
421
|
-
// lead (#6730 D1: production with neither a database nor the email
|
|
422
|
-
// vars, or a newsletter signup with no database).
|
|
423
|
-
return reply.status(503).send({
|
|
424
|
-
error: "lead_capture_not_configured",
|
|
425
|
-
message: "Lead capture is not configured.",
|
|
426
|
-
});
|
|
427
560
|
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
"
|
|
432
|
-
? "skipped"
|
|
433
|
-
: result.email.ok
|
|
434
|
-
? "ok"
|
|
435
|
-
: "error";
|
|
436
|
-
request.log.info(
|
|
437
|
-
{
|
|
438
|
-
siteId: workspaceId,
|
|
439
|
-
kind,
|
|
440
|
-
ip: sourceMeta.ip,
|
|
441
|
-
persisted: result.persisted,
|
|
442
|
-
emailKind,
|
|
443
|
-
replayed: result.replayed === true,
|
|
444
|
-
},
|
|
445
|
-
"lead routed",
|
|
561
|
+
} catch (error) {
|
|
562
|
+
request.log.error(
|
|
563
|
+
{ event: "magnet-completion", error: error instanceof Error ? error.name : "unknown" },
|
|
564
|
+
"lead magnet completion read failed",
|
|
446
565
|
);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
447
568
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
// consults it (#5970 security review round 3, M1): the credential is
|
|
459
|
-
// minted by an update on the `callbacks` row, so a FILE-FALLBACK id
|
|
460
|
-
// would match nothing and the operator would be left with no email at
|
|
461
|
-
// all. On that path the generic notification is sent instead, which is
|
|
462
|
-
// what keeps "exactly one" true in both directions.
|
|
463
|
-
// A spoke's callback never takes this path (#7532 D4, SEC-7532-2): the
|
|
464
|
-
// link is minted on the deployment's one Stripe account.
|
|
465
|
-
if (
|
|
466
|
-
kind === "callback" &&
|
|
467
|
-
result.persisted === "db" &&
|
|
468
|
-
caller.kind !== "spoke"
|
|
469
|
-
) {
|
|
470
|
-
const callbackInput = buildInput(
|
|
471
|
-
kind,
|
|
472
|
-
body,
|
|
473
|
-
workspaceId,
|
|
474
|
-
sourceMeta.receivedAt,
|
|
475
|
-
) as Extract<RouteLeadInput, { kind: "callback" }>;
|
|
476
|
-
sendRequestConfirmLink(result.id, {
|
|
477
|
-
name: callbackInput.name,
|
|
478
|
-
phone: callbackInput.phone,
|
|
479
|
-
...(callbackInput.preferredTime !== undefined && {
|
|
480
|
-
preferredTime: callbackInput.preferredTime,
|
|
481
|
-
}),
|
|
482
|
-
});
|
|
483
|
-
}
|
|
569
|
+
// Fire-and-forget: track() never throws; no need to hold the response.
|
|
570
|
+
// #7869 D6 — a replay records nothing new: the first write counted it.
|
|
571
|
+
if (!result.replayed) {
|
|
572
|
+
void track("lead-captured", {
|
|
573
|
+
source: body["source"] as string | undefined,
|
|
574
|
+
locale: (body["payload"] as Record<string, unknown> | undefined)?.[
|
|
575
|
+
"locale"
|
|
576
|
+
] as string | undefined,
|
|
577
|
+
});
|
|
578
|
+
}
|
|
484
579
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
result.persisted === "db" &&
|
|
493
|
-
typeof source === "string" &&
|
|
494
|
-
source.startsWith("flow:")
|
|
495
|
-
) {
|
|
496
|
-
const slug = source.slice("flow:".length);
|
|
497
|
-
try {
|
|
498
|
-
const completion = await magnetCompletionWithinBudget(workspaceId, slug, () =>
|
|
499
|
-
accountTargetFor(caller),
|
|
500
|
-
);
|
|
501
|
-
if (completion !== null) {
|
|
502
|
-
next = completion.next;
|
|
503
|
-
if (completion.next.completion === "invite" && !result.replayed) {
|
|
504
|
-
const submitInput = buildInput(
|
|
505
|
-
kind,
|
|
506
|
-
body,
|
|
507
|
-
workspaceId,
|
|
508
|
-
sourceMeta.receivedAt,
|
|
509
|
-
) as Extract<RouteLeadInput, { kind: "submit" }>;
|
|
510
|
-
const locale = submitInput.payload["locale"];
|
|
511
|
-
void sendInviteConfirmation({
|
|
512
|
-
workspaceId,
|
|
513
|
-
slug,
|
|
514
|
-
email: submitInput.email,
|
|
515
|
-
locale: typeof locale === "string" ? locale : undefined,
|
|
516
|
-
completion,
|
|
517
|
-
now: new Date(),
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
} catch (error) {
|
|
522
|
-
request.log.error(
|
|
523
|
-
{ event: "magnet-completion", error: error instanceof Error ? error.name : "unknown" },
|
|
524
|
-
"lead magnet completion read failed",
|
|
525
|
-
);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
580
|
+
return reply.status(201).send({
|
|
581
|
+
id: result.id,
|
|
582
|
+
persisted: result.persisted,
|
|
583
|
+
email: result.email,
|
|
584
|
+
...(next === undefined ? {} : { next }),
|
|
585
|
+
});
|
|
586
|
+
}
|
|
528
587
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
void track("lead-captured", {
|
|
533
|
-
source: body["source"] as string | undefined,
|
|
534
|
-
locale: (body["payload"] as Record<string, unknown> | undefined)?.[
|
|
535
|
-
"locale"
|
|
536
|
-
] as string | undefined,
|
|
537
|
-
});
|
|
538
|
-
}
|
|
588
|
+
export const leadSubmitRoute: FastifyPluginAsync = async (fastify) => {
|
|
589
|
+
fastify.post("/v1/leads/submit", async (request, reply) => {
|
|
590
|
+
const body = request.body as Record<string, unknown>;
|
|
539
591
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
592
|
+
// #8730 D1, #9130 — a contact lead or a job application carries the
|
|
593
|
+
// visitor's terms acceptance, or it is refused before the body gate, the
|
|
594
|
+
// tenant read and any write.
|
|
595
|
+
if (isConsentLeadSource(body?.["source"]) && body?.["consent"] !== true) {
|
|
596
|
+
return reply.status(400).send(CONSENT_REQUIRED_BODY);
|
|
597
|
+
}
|
|
598
|
+
const parsed = leadSubmitBodySchema.safeParse(request.body);
|
|
599
|
+
if (!parsed.success) return refuseBody(reply, parsed.error.issues);
|
|
600
|
+
return captureLead("submit", request, reply);
|
|
601
|
+
});
|
|
602
|
+
};
|
|
549
603
|
|
|
550
|
-
export const
|
|
551
|
-
|
|
552
|
-
|
|
604
|
+
export const leadSubscribeRoute: FastifyPluginAsync = async (fastify) => {
|
|
605
|
+
fastify.post("/v1/leads/subscribe", async (request, reply) => {
|
|
606
|
+
const parsed = leadSubscribeBodySchema.safeParse(request.body);
|
|
607
|
+
if (!parsed.success) return refuseBody(reply, parsed.error.issues);
|
|
608
|
+
return captureLead("subscribe", request, reply);
|
|
609
|
+
});
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
export const leadCallbackRoute: FastifyPluginAsync = async (fastify) => {
|
|
613
|
+
fastify.post("/v1/leads/callback", async (request, reply) => {
|
|
614
|
+
const parsed = leadCallbackBodySchema.safeParse(request.body);
|
|
615
|
+
if (!parsed.success) return refuseBody(reply, parsed.error.issues);
|
|
616
|
+
return captureLead("callback", request, reply);
|
|
617
|
+
});
|
|
618
|
+
};
|
|
553
619
|
|
|
554
620
|
/**
|
|
555
621
|
* `GET /v1/leads/capture` (#6730 D4) — two booleans and nothing else: never
|