create-ailk 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -74,6 +74,13 @@ export interface TenantContext {
74
74
  * `@working-theory/validation`'s `allows`.
75
75
  */
76
76
  grants?: readonly AgentGrantablePermission[];
77
+ /**
78
+ * Set ONLY by `agentTenant` (#8514 D4); a person's tenant never carries it.
79
+ * `@working-theory/validation`'s `roleAllows` / `allows` read it to withhold
80
+ * `AGENT_WITHHELD_PERMISSIONS` from an agent's role, and both tenant guards
81
+ * refuse an agent request whose tenant lacks it (fail closed).
82
+ */
83
+ agent?: true;
77
84
  }
78
85
 
79
86
  export type TenantErrorKind = "forbidden" | "not_found" | "conflict";
@@ -222,7 +229,9 @@ export async function resolveDefaultTenantForOperator(
222
229
  * An agent's tenant (#3591 D5, #7864): its person's tenant with the role
223
230
  * clamped to the agent's ceiling and, when any survive, the agent's grants
224
231
  * that its person's own role holds. `grants` is omitted when none survive,
225
- * so an agent with no effective grant is the same shape as before.
232
+ * so an agent with no effective grant is the same shape as before. It always
233
+ * carries `agent: true` (#8514 D4), which withholds the client-keeping rows a
234
+ * member holds by role.
226
235
  *
227
236
  * The one clamp both tenant guards apply — `resolveAgentTenant` (multi-tenant)
228
237
  * and `resolveDefaultTenantForAgent` (base build) — so the two builds cannot
@@ -238,8 +247,8 @@ export function agentTenant(
238
247
  (p) => agent.grants.includes(p) && can(personTenant.role, p),
239
248
  );
240
249
  return grants.length > 0
241
- ? { ...personTenant, role, grants }
242
- : { ...personTenant, role };
250
+ ? { ...personTenant, role, agent: true, grants }
251
+ : { ...personTenant, role, agent: true };
243
252
  }
244
253
 
245
254
  /**
@@ -114,7 +114,8 @@ function refuse<T = never>(
114
114
  // keyed on an identity the request has already proved (the user the cookie
115
115
  // session resolved to; the client that passed secret AND spoke key), so an
116
116
  // invented key never gets one (SEC-7894-8). The shape of `middleware/auth.ts`'s
117
- // per-agent limiter. SINGLE-REPLICA, like every limiter in this app.
117
+ // per-agent limiter. SINGLE-REPLICA, like the in-process limiters in this
118
+ // app (the per-IP middleware shares a database store, #5040).
118
119
 
119
120
  interface Bucket {
120
121
  windowStartMs: number;
@@ -48,8 +48,16 @@
48
48
  * throws, never refuses the lead and never changes `routeLead`'s result: an
49
49
  * unstorable address or a failed step writes the lead with `personId: null`
50
50
  * and logs one bare line (`person-skipped` / `person-failed`, never the
51
- * email). `subscribe` and `callback` create no Person; the `email`, `local`
52
- * and `none` deliveries never reach `persistToDb`.
51
+ * email). The `email`, `local` and `none` deliveries never reach
52
+ * `persistToDb`.
53
+ *
54
+ * CONTACT AND LINKS (#8435 D5). A `submit` may carry `options.contact` (the
55
+ * magnet's tagged answers): `findOrCreatePerson` fills the Person's BLANK
56
+ * contact fields from it while the Person is uncurated, and the lead's own
57
+ * `payload` is unchanged. A `subscribe` and a `callback` with a storable email
58
+ * (a callback's is `payload.email`) now also resolve a Person and stamp its id
59
+ * as `personId`; a callback with no email makes no Person call and writes no
60
+ * `personId`. Every one of these steps is best-effort exactly like the lead's.
53
61
  *
54
62
  * CHECKOUT PLAN (#7877 D10). A spoke's checkout fulfilment posts one
55
63
  * `submit` with the Checkout Session id as its `Idempotency-Key`. When that
@@ -72,6 +80,15 @@
72
80
  * an update; an unmarked create carries no `spokeCheckout` key at all and
73
81
  * relies on the column default. The plan rule above is unchanged: it still
74
82
  * runs after the write, from the same candidate.
83
+ *
84
+ * VISITOR LINK (#8438 D11). When the request carried a valid consent visitor
85
+ * id (`SourceMeta.consentVisitorId`, set by the leads and flows routes from
86
+ * `x-ailk-consent-visitor` only when it is a v4 UUID) and the submit resolved
87
+ * a Person, `linkConsentVisitor` joins the two — source `magnet` for a
88
+ * `flow:` source, else `api`. A link never fails a lead and is never
89
+ * returned: `RouteLeadResult.personId` is internal, for the flow engine's
90
+ * consent write. A subscribe or callback resolves no Person, so it links
91
+ * nothing. The header is never echoed and never logged.
75
92
  */
76
93
 
77
94
  import { randomUUID } from "node:crypto";
@@ -91,8 +108,15 @@ import { appendLeadRecord } from "../lib/lead-capture-store.js";
91
108
  import { acceptsKind, resolveLeadDelivery } from "../lib/lead-delivery.js";
92
109
  import { prisma } from "../lib/prisma.js";
93
110
 
111
+ import { linkConsentVisitor } from "./consent.js";
94
112
  import { enrichLead } from "./enrichment-shim.js";
95
- import { findOrCreatePerson, setPersonPlanOffering } from "./person.js";
113
+ import {
114
+ findOrCreatePerson,
115
+ normalizeCapturedContact,
116
+ normalizeTelephone,
117
+ setPersonPlanOffering,
118
+ } from "./person.js";
119
+ import type { LeadContact, PersonContact } from "./person.js";
96
120
  import { requestConfirmSupersedesNotification } from "./request-confirm.js";
97
121
 
98
122
  // ─── Types ────────────────────────────────────────────────────────────────────
@@ -133,6 +157,12 @@ export type SourceMeta = {
133
157
  * never grant it.
134
158
  */
135
159
  relayed?: boolean;
160
+ /**
161
+ * The cookie banner's visitor id from the request's `x-ailk-consent-visitor`
162
+ * header, set only when it is a v4 UUID (#8438 D11). Used to link the
163
+ * visitor to the lead's Person; never persisted, echoed or logged.
164
+ */
165
+ consentVisitorId?: string;
136
166
  };
137
167
 
138
168
  /** Where the lead landed: a database row, the local file fallback, or the owner's inbox by email (#6730). */
@@ -142,6 +172,12 @@ export type Persisted = "db" | "file" | "email";
142
172
  export type RouteLeadOptions = {
143
173
  /** The caller's validated `Idempotency-Key`; honoured for `submit` only. */
144
174
  idempotencyKey?: string;
175
+ /**
176
+ * The contact the capture offers (#8435 D5) — the magnet's tagged answers —
177
+ * honoured for `submit` only and never persisted as such: the lead's
178
+ * `payload` stays the raw answers.
179
+ */
180
+ contact?: LeadContact;
145
181
  };
146
182
 
147
183
  export type RouteLeadResult =
@@ -154,6 +190,12 @@ export type RouteLeadResult =
154
190
  */
155
191
  replayed?: true;
156
192
  id: string;
193
+ /**
194
+ * The Person a `submit` resolved (#8438 D11), `null`/absent when none.
195
+ * Internal only — the route builds its 201 body from `id`, `persisted`
196
+ * and `email`, so this never reaches a wire.
197
+ */
198
+ personId?: string | null;
157
199
  persisted: Persisted;
158
200
  email:
159
201
  | { ok: true; messageId: string }
@@ -256,6 +298,8 @@ type PersistResult =
256
298
  source: "db";
257
299
  skipEmail: boolean;
258
300
  skipReason?: string;
301
+ /** The Person a `submit` resolved (#8438 D11). */
302
+ personId?: string | null;
259
303
  }
260
304
  // #7869 D5 — a keyed create lost a race to a row it now answers with.
261
305
  | { ok: "keyed"; outcome: KeyedOutcome }
@@ -267,25 +311,41 @@ type PersistResult =
267
311
  * must never refuse, lose or delay a lead. Logs the outcome kind and the
268
312
  * site only.
269
313
  */
270
- async function resolveLeadPersonId(input: SubmitInput): Promise<string | null> {
314
+ async function resolveCapturePersonId(
315
+ siteId: string,
316
+ capture: { email: string; name?: string | null; contact?: Partial<PersonContact> },
317
+ ): Promise<string | null> {
271
318
  try {
272
- const name = typeof input.payload["name"] === "string" ? input.payload["name"] : null;
273
- const person = await findOrCreatePerson(input.siteId, { email: input.email, name });
319
+ const person = await findOrCreatePerson(siteId, capture);
274
320
  if (person.ok) return person.value.personId;
275
321
  if (person.error.kind === "invalid_email") {
276
322
  logger.warn(
277
- { event: "person-skipped", kind: person.error.kind, siteId: input.siteId },
323
+ { event: "person-skipped", kind: person.error.kind, siteId },
278
324
  "lead person skipped",
279
325
  );
280
326
  } else {
281
- logger.error({ event: "person-failed", siteId: input.siteId }, "lead person failed");
327
+ logger.error({ event: "person-failed", siteId }, "lead person failed");
282
328
  }
283
329
  } catch {
284
- logger.error({ event: "person-failed", siteId: input.siteId }, "lead person failed");
330
+ logger.error({ event: "person-failed", siteId }, "lead person failed");
285
331
  }
286
332
  return null;
287
333
  }
288
334
 
335
+ async function resolveLeadPersonId(input: SubmitInput, contact?: LeadContact): Promise<string | null> {
336
+ const payloadName = typeof input.payload["name"] === "string" ? input.payload["name"] : null;
337
+ if (contact === undefined) {
338
+ return resolveCapturePersonId(input.siteId, { email: input.email, name: payloadName });
339
+ }
340
+ // #8435 D5 — an explicit contact name wins over the payload's.
341
+ const name = typeof contact.name === "string" ? contact.name : payloadName;
342
+ return resolveCapturePersonId(input.siteId, {
343
+ email: input.email,
344
+ name,
345
+ contact: normalizeCapturedContact(contact),
346
+ });
347
+ }
348
+
289
349
  /** A LIVE-mode Checkout Session id (#7877 D10, SEC-7877-C). */
290
350
  const LIVE_CHECKOUT_SESSION_KEY = /^cs_live_[A-Za-z0-9]{1,250}$/;
291
351
  const SITE_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
@@ -357,9 +417,10 @@ async function persistToDb(
357
417
  idempotencyKey?: string,
358
418
  viaSpoke = false,
359
419
  relayed = false,
420
+ contact?: LeadContact,
360
421
  ): Promise<PersistResult> {
361
422
  if (input.kind === "submit") {
362
- const personId = await resolveLeadPersonId(input);
423
+ const personId = await resolveLeadPersonId(input, contact);
363
424
  // #8185 D2 — the plan rule's own predicate (pure: input, key, flag),
364
425
  // evaluated before the create so its result can be stored. A relayed
365
426
  // request is never a spoke's server call (D3).
@@ -413,10 +474,12 @@ async function persistToDb(
413
474
  if (candidate !== null && personId !== null) {
414
475
  await applyCheckoutPlan(input, personId, candidate);
415
476
  }
416
- return { ok: true, id: row.id, source: "db", skipEmail: false };
477
+ return { ok: true, id: row.id, source: "db", skipEmail: false, personId };
417
478
  }
418
479
 
419
480
  if (input.kind === "subscribe") {
481
+ // #8435 D5 — the subscriber's Person; `null` when the step skips or fails.
482
+ const personId = await resolveCapturePersonId(input.siteId, { email: input.email });
420
483
  try {
421
484
  const row = (await prisma.subscriber.create({
422
485
  data: {
@@ -430,6 +493,7 @@ async function persistToDb(
430
493
  workspaceId: input.siteId,
431
494
  email: input.email,
432
495
  source: input.source,
496
+ personId,
433
497
  },
434
498
  select: { id: true },
435
499
  })) as DbRow;
@@ -467,6 +531,18 @@ async function persistToDb(
467
531
  }
468
532
 
469
533
  // kind === "callback"
534
+ // #8435 D5 — a callback with a string `payload.email` resolves a Person; one
535
+ // without makes no call and writes no `personId`.
536
+ const callbackEmail = input.payload["email"];
537
+ const telephone = normalizeTelephone(input.phone);
538
+ const callbackPersonId =
539
+ typeof callbackEmail === "string"
540
+ ? await resolveCapturePersonId(input.siteId, {
541
+ email: callbackEmail,
542
+ name: input.name,
543
+ contact: telephone === null ? {} : { telephone },
544
+ })
545
+ : undefined;
470
546
  const row = (await prisma.callback.create({
471
547
  data: {
472
548
  siteId: input.siteId,
@@ -480,6 +556,7 @@ async function persistToDb(
480
556
  preferredTime: input.preferredTime,
481
557
  }),
482
558
  payload: input.payload as Prisma.InputJsonValue,
559
+ ...(callbackPersonId !== undefined && { personId: callbackPersonId }),
483
560
  },
484
561
  select: { id: true },
485
562
  })) as DbRow;
@@ -750,6 +827,7 @@ export async function routeLead(
750
827
  let persisted: Persisted;
751
828
  let skipEmail = false;
752
829
  let skipReason: string | undefined;
830
+ let personId: string | null = null;
753
831
 
754
832
  try {
755
833
  const dbResult = await persistToDb(
@@ -757,6 +835,7 @@ export async function routeLead(
757
835
  idempotencyKey,
758
836
  sourceMeta.viaSpoke === true,
759
837
  sourceMeta.relayed === true,
838
+ input.kind === "submit" ? options.contact : undefined,
760
839
  );
761
840
  // #7869 D5 — a lost race answers like the lookup above: no capture
762
841
  // record, no notification.
@@ -772,6 +851,20 @@ export async function routeLead(
772
851
  persisted = "db";
773
852
  skipEmail = dbResult.skipEmail;
774
853
  skipReason = dbResult.skipReason;
854
+ personId = dbResult.personId ?? null;
855
+ // #8438 D11 — only after THIS request's own write (a keyed replay
856
+ // returned above), and only when a Person was resolved.
857
+ if (sourceMeta.consentVisitorId !== undefined && personId !== null) {
858
+ await linkConsentVisitor({
859
+ workspaceId: input.siteId,
860
+ visitorId: sourceMeta.consentVisitorId,
861
+ personId,
862
+ source:
863
+ input.kind === "submit" && input.source.startsWith("flow:")
864
+ ? "magnet"
865
+ : "api",
866
+ });
867
+ }
775
868
  } catch (dbErr) {
776
869
  // #6254 AC-11 (spec D6, SEC-02) — a constraint violation is OUR OWN
777
870
  // derivation producing a row Postgres itself refuses (an unresolved
@@ -891,5 +984,5 @@ export async function routeLead(
891
984
  emailResult = await sendEmail(input, id!, persisted!, sourceMeta.viaSpoke);
892
985
  }
893
986
 
894
- return { ok: true, id: id!, persisted, email: emailResult };
987
+ return { ok: true, id: id!, personId, persisted, email: emailResult };
895
988
  }