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
@@ -21,6 +21,10 @@
21
21
  * only while its `userId` is the caller.
22
22
  * - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
23
23
  * the Admin's plan route and the checkout-lead rule in `lead-routing.ts`.
24
+ * - the contact details (#8435): `findOrCreatePerson` fills BLANK contact
25
+ * fields of an uncurated Person (D4); `updatePersonContact` (the
26
+ * operator's save), `ensureMemberPerson` and `setOwnPersonContact` (the
27
+ * client's save) overwrite and stamp who confirmed them (D9, D11).
24
28
  *
25
29
  * ## Match order (D3) — fixed, and there is no merge
26
30
  *
@@ -76,6 +80,7 @@
76
80
 
77
81
  import type { Prisma } from "@working-theory/database";
78
82
  import type { Id } from "@working-theory/ids";
83
+ import type { PersonContactPatch } from "@working-theory/validation";
79
84
 
80
85
  import { prisma } from "../lib/prisma.js";
81
86
 
@@ -127,6 +132,317 @@ export function normalizePersonName(raw: unknown): string | null {
127
132
  return cleaned === "" ? null : cleaned;
128
133
  }
129
134
 
135
+ // ─── Contact normalization (#8435 D3) ────────────────────────────────────────
136
+
137
+ export type PersonAddress = {
138
+ addressLine1: string;
139
+ addressLine2: string | null;
140
+ city: string;
141
+ region: string;
142
+ postalCode: string;
143
+ country: string;
144
+ };
145
+
146
+ export type PersonContact = {
147
+ givenName: string | null;
148
+ familyName: string | null;
149
+ telephone: string | null;
150
+ address: PersonAddress | null;
151
+ companyName: string | null;
152
+ jobTitle: string | null;
153
+ };
154
+
155
+ /**
156
+ * What a capture offers, as untrusted `unknown` values: the magnet's tagged
157
+ * answers or a caller's explicit contact (`RouteLeadOptions.contact`).
158
+ */
159
+ export type LeadContact = {
160
+ name?: unknown;
161
+ givenName?: unknown;
162
+ familyName?: unknown;
163
+ phone?: unknown;
164
+ address?: unknown;
165
+ company?: unknown;
166
+ jobTitle?: unknown;
167
+ };
168
+
169
+ /** The widths of the person-level text columns, in code points. */
170
+ const CONTACT_MAX = {
171
+ givenName: 100,
172
+ familyName: 100,
173
+ companyName: 200,
174
+ jobTitle: 100,
175
+ } as const;
176
+ /** The address parts' widths — `ADDRESS_PART_MAX_LENGTHS` in `packages/validation`. */
177
+ const ADDRESS_MAX = {
178
+ addressLine1: 100,
179
+ addressLine2: 60,
180
+ city: 60,
181
+ region: 40,
182
+ postalCode: 20,
183
+ } as const;
184
+ const E164 = /^\+[1-9]\d{6,14}$/;
185
+ const SHORT_REGION = /^[A-Za-z0-9]{1,3}$/;
186
+ const ALPHA2 = /^[A-Z]{2}$/;
187
+ /**
188
+ * The code-point ranges `CONTACT_STRIP` removes (SEC-8435-L): `NAME_STRIP`'s
189
+ * C0/C1 and bidi controls plus the zero-width characters U+200B–U+200D and
190
+ * U+FEFF and the separators U+2028/U+2029. Numbers, not a regex literal, so no
191
+ * tool can rewrite a separator into a line break inside it. The same set
192
+ * `packages/validation`'s `people-contact.ts` strips on the wire.
193
+ */
194
+ const CONTACT_STRIPPED: ReadonlyArray<readonly [number, number]> = [
195
+ [0x0000, 0x001f],
196
+ [0x007f, 0x009f],
197
+ [0x200b, 0x200f],
198
+ [0x2028, 0x2029],
199
+ [0x202a, 0x202e],
200
+ [0x2066, 0x2069],
201
+ [0xfeff, 0xfeff],
202
+ ];
203
+
204
+ function stripContactChars(raw: string): string {
205
+ let out = "";
206
+ for (const ch of raw) {
207
+ const cp = ch.codePointAt(0) ?? 0;
208
+ if (!CONTACT_STRIPPED.some(([lo, hi]) => cp >= lo && cp <= hi)) out += ch;
209
+ }
210
+ return out;
211
+ }
212
+
213
+ const cutToCodePoints = (value: string, max: number): string =>
214
+ Array.from(value).slice(0, max).join("");
215
+
216
+ /** Stripped, trimmed, cut to `max` code points; empty or not a string → `null`. */
217
+ function normalizeContactText(raw: unknown, max: number): string | null {
218
+ if (typeof raw !== "string") return null;
219
+ const cleaned = cutToCodePoints(stripContactChars(raw).trim(), max);
220
+ return cleaned === "" ? null : cleaned;
221
+ }
222
+
223
+ /**
224
+ * Strip spaces, `( ) . -` (and the invisible characters), then the result
225
+ * must be an E.164 number — else `null`.
226
+ */
227
+ export function normalizeTelephone(raw: unknown): string | null {
228
+ if (typeof raw !== "string") return null;
229
+ const compact = stripContactChars(raw)
230
+ .replace(/[ ().-]/g, "")
231
+ .trim();
232
+ return E164.test(compact) ? compact : null;
233
+ }
234
+
235
+ /**
236
+ * An address as stored: whole or not at all. Every required part must be a
237
+ * non-empty string within its width after stripping; a short region is
238
+ * upper-cased (C3); the country must be an upper-case alpha-2 code.
239
+ */
240
+ function normalizeAddress(raw: unknown): PersonAddress | null {
241
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw))
242
+ return null;
243
+ const value = raw as Record<string, unknown>;
244
+ const part = (key: keyof typeof ADDRESS_MAX): string | null => {
245
+ const field = value[key];
246
+ if (typeof field !== "string") return null;
247
+ const cleaned = stripContactChars(field).trim();
248
+ return cleaned === "" || Array.from(cleaned).length > ADDRESS_MAX[key]
249
+ ? null
250
+ : cleaned;
251
+ };
252
+ const addressLine1 = part("addressLine1");
253
+ const city = part("city");
254
+ const region = part("region");
255
+ const postalCode = part("postalCode");
256
+ const country = value["country"];
257
+ if (
258
+ addressLine1 === null ||
259
+ city === null ||
260
+ region === null ||
261
+ postalCode === null ||
262
+ typeof country !== "string" ||
263
+ !ALPHA2.test(country)
264
+ ) {
265
+ return null;
266
+ }
267
+ const line2Raw = value["addressLine2"];
268
+ const line2 = part("addressLine2");
269
+ // An over-long or non-string second line makes the address unusable; an
270
+ // absent or blank one is simply null.
271
+ if (line2 === null && line2Raw !== undefined && line2Raw !== null) {
272
+ if (
273
+ typeof line2Raw !== "string" ||
274
+ stripContactChars(line2Raw).trim() !== ""
275
+ )
276
+ return null;
277
+ }
278
+ return {
279
+ addressLine1,
280
+ addressLine2: line2,
281
+ city,
282
+ region: SHORT_REGION.test(region) ? region.toUpperCase() : region,
283
+ postalCode,
284
+ country,
285
+ };
286
+ }
287
+
288
+ /** Capture-side: every usable field, each normalized; unusable ones dropped. */
289
+ export function normalizeCapturedContact(
290
+ raw: LeadContact,
291
+ ): Partial<PersonContact> {
292
+ const out: Partial<PersonContact> = {};
293
+ const givenName = normalizeContactText(raw.givenName, CONTACT_MAX.givenName);
294
+ if (givenName !== null) out.givenName = givenName;
295
+ const familyName = normalizeContactText(
296
+ raw.familyName,
297
+ CONTACT_MAX.familyName,
298
+ );
299
+ if (familyName !== null) out.familyName = familyName;
300
+ const telephone = normalizeTelephone(raw.phone);
301
+ if (telephone !== null) out.telephone = telephone;
302
+ const address = normalizeAddress(raw.address);
303
+ if (address !== null) out.address = address;
304
+ const companyName = normalizeContactText(
305
+ raw.company,
306
+ CONTACT_MAX.companyName,
307
+ );
308
+ if (companyName !== null) out.companyName = companyName;
309
+ const jobTitle = normalizeContactText(raw.jobTitle, CONTACT_MAX.jobTitle);
310
+ if (jobTitle !== null) out.jobTitle = jobTitle;
311
+ return out;
312
+ }
313
+
314
+ // ─── The contact columns, as rows and as the wire's shape ───────────────────
315
+
316
+ /** The thirteen contact columns a read names (`select`). */
317
+ export const PERSON_CONTACT_SELECT = {
318
+ givenName: true,
319
+ familyName: true,
320
+ telephone: true,
321
+ addressLine1: true,
322
+ addressLine2: true,
323
+ addressCity: true,
324
+ addressRegion: true,
325
+ addressPostalCode: true,
326
+ addressCountry: true,
327
+ companyName: true,
328
+ jobTitle: true,
329
+ contactConfirmedAt: true,
330
+ contactConfirmedBy: true,
331
+ } as const;
332
+
333
+ export type PersonContactRow = {
334
+ givenName: string | null;
335
+ familyName: string | null;
336
+ telephone: string | null;
337
+ addressLine1: string | null;
338
+ addressLine2: string | null;
339
+ addressCity: string | null;
340
+ addressRegion: string | null;
341
+ addressPostalCode: string | null;
342
+ addressCountry: string | null;
343
+ companyName: string | null;
344
+ jobTitle: string | null;
345
+ contactConfirmedAt: Date | null;
346
+ contactConfirmedBy: string | null;
347
+ };
348
+
349
+ /** The wire's contact: every key present, `null` where blank, the address whole or `null`. */
350
+ export function personContactFromRow(row: PersonContactRow): PersonContact {
351
+ // `?? null` so a row that does not carry a column reads as blank, never as
352
+ // `undefined` on the wire.
353
+ const line1 = row.addressLine1 ?? null;
354
+ const city = row.addressCity ?? null;
355
+ const region = row.addressRegion ?? null;
356
+ const postalCode = row.addressPostalCode ?? null;
357
+ const country = row.addressCountry ?? null;
358
+ const address: PersonAddress | null =
359
+ line1 !== null && city !== null && region !== null && postalCode !== null && country !== null
360
+ ? {
361
+ addressLine1: line1,
362
+ addressLine2: row.addressLine2 ?? null,
363
+ city,
364
+ region,
365
+ postalCode,
366
+ country,
367
+ }
368
+ : null;
369
+ return {
370
+ givenName: row.givenName ?? null,
371
+ familyName: row.familyName ?? null,
372
+ telephone: row.telephone ?? null,
373
+ address,
374
+ companyName: row.companyName ?? null,
375
+ jobTitle: row.jobTitle ?? null,
376
+ };
377
+ }
378
+
379
+ /** Who last saved the contact by hand, and when — `null` while only captures have written it. */
380
+ export function contactConfirmedFromRow(
381
+ row: Pick<PersonContactRow, "contactConfirmedAt" | "contactConfirmedBy">,
382
+ ): { by: "operator" | "client"; at: string } | null {
383
+ if (row.contactConfirmedAt === null || row.contactConfirmedAt === undefined)
384
+ return null;
385
+ if (
386
+ row.contactConfirmedBy !== "operator" &&
387
+ row.contactConfirmedBy !== "client"
388
+ )
389
+ return null;
390
+ return {
391
+ by: row.contactConfirmedBy,
392
+ at: row.contactConfirmedAt.toISOString(),
393
+ };
394
+ }
395
+
396
+ type AddressColumns = {
397
+ addressLine1: string | null;
398
+ addressLine2: string | null;
399
+ addressCity: string | null;
400
+ addressRegion: string | null;
401
+ addressPostalCode: string | null;
402
+ addressCountry: string | null;
403
+ };
404
+
405
+ /** The six address columns for an address — all six `null` for `null`. */
406
+ function addressColumns(address: PersonAddress | null): AddressColumns {
407
+ return {
408
+ addressLine1: address?.addressLine1 ?? null,
409
+ addressLine2: address?.addressLine2 ?? null,
410
+ addressCity: address?.city ?? null,
411
+ addressRegion: address?.region ?? null,
412
+ addressPostalCode: address?.postalCode ?? null,
413
+ addressCountry: address?.country ?? null,
414
+ };
415
+ }
416
+
417
+ /** The scalar contact fields, each its own column of the same name. */
418
+ const SCALAR_CONTACT_FIELDS = [
419
+ "givenName",
420
+ "familyName",
421
+ "telephone",
422
+ "companyName",
423
+ "jobTitle",
424
+ ] as const;
425
+
426
+ /** Every supplied contact field as insert columns. */
427
+ function contactInsertColumns(
428
+ contact: Partial<PersonContact>,
429
+ ): Record<string, string | null> {
430
+ const columns: Record<string, string | null> = {};
431
+ for (const field of SCALAR_CONTACT_FIELDS) {
432
+ const value = contact[field];
433
+ if (value !== undefined && value !== null) columns[field] = value;
434
+ }
435
+ if (contact.address) Object.assign(columns, addressColumns(contact.address));
436
+ return columns;
437
+ }
438
+
439
+ /** `"Ada" + "Lovelace"` → `"Ada Lovelace"`, `"Ada"` alone → `"Ada"`, neither → `null`. */
440
+ function joinedName(contact: Partial<PersonContact>): string | null {
441
+ return normalizePersonName(
442
+ [contact.givenName, contact.familyName].filter((part) => part).join(" "),
443
+ );
444
+ }
445
+
130
446
  function usableCustomerId(raw: string | null | undefined): string | null {
131
447
  return typeof raw === "string" &&
132
448
  raw.length > 0 &&
@@ -156,6 +472,16 @@ type MatchedPerson = {
156
472
  name: string | null;
157
473
  userId: string | null;
158
474
  billingCustomerId: string | null;
475
+ // #8435 D4.4 — the blank-sentinels the contact fill reads. A value reading
476
+ // `undefined` (a test double that does not carry the key) is "not blank",
477
+ // and an undefined `contactConfirmedAt` is "confirmed": no fill runs.
478
+ givenName?: string | null;
479
+ familyName?: string | null;
480
+ telephone?: string | null;
481
+ addressLine1?: string | null;
482
+ companyName?: string | null;
483
+ jobTitle?: string | null;
484
+ contactConfirmedAt?: Date | null;
159
485
  };
160
486
 
161
487
  const MATCH_SELECT = {
@@ -163,6 +489,13 @@ const MATCH_SELECT = {
163
489
  name: true,
164
490
  userId: true,
165
491
  billingCustomerId: true,
492
+ givenName: true,
493
+ familyName: true,
494
+ telephone: true,
495
+ addressLine1: true,
496
+ companyName: true,
497
+ jobTitle: true,
498
+ contactConfirmedAt: true,
166
499
  } as const;
167
500
 
168
501
  /**
@@ -199,6 +532,53 @@ async function enrichMatched(
199
532
  return billing;
200
533
  }
201
534
 
535
+ /**
536
+ * The contact fill on an email match (#8435 D4.3): for each supplied field
537
+ * whose stored value is `null`, one guarded `updateMany`; the address is one
538
+ * statement guarded on `addressLine1: null` writing all six. It runs ONLY
539
+ * while the Person is uncurated — linked to no user and never saved by hand
540
+ * (`contactConfirmedAt` null) — and every guard repeats that condition, so a
541
+ * link or a save that lands between the read and the write still wins
542
+ * (SEC-8435-A). A stored value, whoever wrote it, is never overwritten.
543
+ * Best-effort: a failed write is swallowed, never refusing the capture.
544
+ */
545
+ async function fillBlankContact(
546
+ db: PersonDb,
547
+ workspaceId: string,
548
+ person: MatchedPerson,
549
+ contact: Partial<PersonContact>,
550
+ plainName: string | null,
551
+ ): Promise<void> {
552
+ if (person.userId !== null || person.contactConfirmedAt !== null) return;
553
+ const uncurated = { id: person.id, workspaceId, userId: null, contactConfirmedAt: null };
554
+ try {
555
+ for (const field of SCALAR_CONTACT_FIELDS) {
556
+ const value = contact[field];
557
+ if (value !== undefined && value !== null && person[field] === null) {
558
+ await db.person.updateMany({
559
+ where: { ...uncurated, [field]: null } as Prisma.PersonWhereInput,
560
+ data: { [field]: value } as Prisma.PersonUpdateManyMutationInput,
561
+ });
562
+ }
563
+ }
564
+ if (contact.address && person.addressLine1 === null) {
565
+ await db.person.updateMany({
566
+ where: { ...uncurated, addressLine1: null },
567
+ data: addressColumns(contact.address),
568
+ });
569
+ }
570
+ // The display name from given + family, only when nothing named it.
571
+ if (plainName === null && person.name === null) {
572
+ const derived = joinedName(contact);
573
+ if (derived !== null) {
574
+ await db.person.updateMany({ where: { ...uncurated, name: null }, data: { name: derived } });
575
+ }
576
+ }
577
+ } catch {
578
+ // A contact fill never changes the capture's outcome.
579
+ }
580
+ }
581
+
202
582
  /**
203
583
  * Find this workspace's Person for an email, or create it.
204
584
  *
@@ -206,6 +586,9 @@ async function enrichMatched(
206
586
  * @param input.email the raw address; normalized here.
207
587
  * @param input.name an optional display name, from anonymous text.
208
588
  * @param input.billingCustomerId an optional provider customer id.
589
+ * @param input.contact optional, already-normalized contact fields
590
+ * (`normalizeCapturedContact`): a created Person carries them all; a matched
591
+ * uncurated Person gains only the ones it has blank (#8435 D4).
209
592
  * @param db the shared client by default; the appointment path passes its
210
593
  * transaction client so the step runs on the lock's own connection.
211
594
  */
@@ -215,6 +598,7 @@ export async function findOrCreatePerson(
215
598
  email: string;
216
599
  name?: string | null;
217
600
  billingCustomerId?: string | null;
601
+ contact?: Partial<PersonContact>;
218
602
  },
219
603
  db: PersonDb = prisma,
220
604
  ): Promise<FindOrCreatePersonResult> {
@@ -225,6 +609,8 @@ export async function findOrCreatePerson(
225
609
  }
226
610
  const name = normalizePersonName(input.name);
227
611
  const customerId = usableCustomerId(input.billingCustomerId);
612
+ const contact = input.contact ?? {};
613
+ const hasContact = Object.keys(contact).length > 0;
228
614
 
229
615
  try {
230
616
  // Step 2 — a customer id names its Person; nothing is written.
@@ -253,6 +639,7 @@ export async function findOrCreatePerson(
253
639
  });
254
640
  if (byEmail) {
255
641
  const billing = await enrichMatched(db, workspaceId, byEmail, name, customerId);
642
+ if (hasContact) await fillBlankContact(db, workspaceId, byEmail, contact, name);
256
643
  return {
257
644
  ok: true,
258
645
  value: { personId: byEmail.id as Id<"person">, created: false, billing },
@@ -265,11 +652,13 @@ export async function findOrCreatePerson(
265
652
  {
266
653
  workspaceId,
267
654
  email,
268
- name,
655
+ // A supplied name wins; else given + family joined (#8435 D4.2).
656
+ name: name ?? joinedName(contact),
269
657
  ...(customerId !== null && {
270
658
  billingProvider: "stripe",
271
659
  billingCustomerId: customerId,
272
660
  }),
661
+ ...(hasContact && contactInsertColumns(contact)),
273
662
  },
274
663
  ],
275
664
  skipDuplicates: true,
@@ -291,6 +680,7 @@ export async function findOrCreatePerson(
291
680
  }
292
681
  // A concurrent create won the race: treat its row as an email match.
293
682
  const billing = await enrichMatched(db, workspaceId, winner, name, customerId);
683
+ if (hasContact) await fillBlankContact(db, workspaceId, winner, contact, name);
294
684
  return {
295
685
  ok: true,
296
686
  value: { personId: winner.id as Id<"person">, created: false, billing },
@@ -539,3 +929,113 @@ export async function setPersonPlanOffering(
539
929
  return "internal_error";
540
930
  }
541
931
  }
932
+
933
+ // ─── The two human edits of the contact (#8435 D9, D11) ───────────────────────
934
+
935
+ export type SavedContact = {
936
+ contact: PersonContact;
937
+ confirmed: { by: "operator" | "client"; at: string } | null;
938
+ };
939
+
940
+ export type SaveContactOutcome =
941
+ | { kind: "saved"; value: SavedContact }
942
+ | { kind: "not_found" | "internal_error" };
943
+
944
+ /** The columns a patch writes: only the keys present; the address key writes (or clears) all six. */
945
+ function patchColumns(patch: PersonContactPatch): Record<string, string | null> {
946
+ const columns: Record<string, string | null> = {};
947
+ for (const field of SCALAR_CONTACT_FIELDS) {
948
+ const value = patch[field];
949
+ if (value !== undefined) columns[field] = value;
950
+ }
951
+ if (patch.address !== undefined) Object.assign(columns, addressColumns(patch.address));
952
+ return columns;
953
+ }
954
+
955
+ async function readSavedContact(workspaceId: string, personId: string): Promise<SaveContactOutcome> {
956
+ const row = await prisma.person.findFirst({
957
+ where: { id: personId, workspaceId },
958
+ select: PERSON_CONTACT_SELECT,
959
+ });
960
+ if (!row) return { kind: "not_found" };
961
+ return { kind: "saved", value: { contact: personContactFromRow(row), confirmed: contactConfirmedFromRow(row) } };
962
+ }
963
+
964
+ /**
965
+ * The operator's save (#8435 D9): one `updateMany` scoped by the
966
+ * server-resolved workspace, writing only the keys present plus the
967
+ * confirmation stamp (`operator`). No row → `not_found`. Never throws.
968
+ */
969
+ export async function updatePersonContact(
970
+ workspaceId: string,
971
+ personId: string,
972
+ patch: PersonContactPatch,
973
+ ): Promise<SaveContactOutcome> {
974
+ try {
975
+ const { count } = await prisma.person.updateMany({
976
+ where: { id: personId, workspaceId },
977
+ data: {
978
+ ...patchColumns(patch),
979
+ contactConfirmedAt: new Date(),
980
+ contactConfirmedBy: "operator",
981
+ } as Prisma.PersonUpdateManyMutationInput,
982
+ });
983
+ if (count === 0) return { kind: "not_found" };
984
+ return await readSavedContact(workspaceId, personId);
985
+ } catch {
986
+ return { kind: "internal_error" };
987
+ }
988
+ }
989
+
990
+ /**
991
+ * The Person a client's first contact save belongs to (#8435 D11): the
992
+ * caller's VERIFIED email's Person in this workspace, found or created, and
993
+ * linked to the caller through `stampPersonUser`'s own rule. Anything but a
994
+ * stamped or already-linked outcome is `unavailable` — nothing is written for
995
+ * the contact. Never throws.
996
+ */
997
+ export async function ensureMemberPerson(
998
+ workspaceId: string,
999
+ userId: string,
1000
+ ): Promise<{ ok: true; personId: string } | { ok: false }> {
1001
+ try {
1002
+ const verified = await findVerifiedEmail(userId);
1003
+ if (!verified.ok) return { ok: false };
1004
+ const person = await findOrCreatePerson(workspaceId, { email: verified.value.email });
1005
+ if (!person.ok) return { ok: false };
1006
+ const outcome = await stampPersonUser(workspaceId, person.value.personId, userId);
1007
+ if (outcome !== "stamped" && outcome !== "already_linked") return { ok: false };
1008
+ return { ok: true, personId: person.value.personId };
1009
+ } catch {
1010
+ return { ok: false };
1011
+ }
1012
+ }
1013
+
1014
+ /**
1015
+ * The client's save (#8435 D11): ONE `updateMany` guarded on `{ id,
1016
+ * workspaceId, userId }` — only the caller's own linked Person is ever
1017
+ * written — replacing all six contact keys with the body and stamping
1018
+ * `client`, so no value the client never saw survives it (SEC-8435-B). No
1019
+ * row → `not_found`. Never throws.
1020
+ */
1021
+ export async function setOwnPersonContact(
1022
+ workspaceId: string,
1023
+ personId: string,
1024
+ userId: string,
1025
+ contact: PersonContactPatch,
1026
+ ): Promise<SaveContactOutcome> {
1027
+ try {
1028
+ const { count } = await prisma.person.updateMany({
1029
+ where: { id: personId, workspaceId, userId },
1030
+ data: {
1031
+ ...patchColumns(contact),
1032
+ contactConfirmedAt: new Date(),
1033
+ contactConfirmedBy: "client",
1034
+ } as Prisma.PersonUpdateManyMutationInput,
1035
+ });
1036
+ if (count === 0) return { kind: "not_found" };
1037
+ return await readSavedContact(workspaceId, personId);
1038
+ } catch {
1039
+ return { kind: "internal_error" };
1040
+ }
1041
+ }
@@ -868,10 +868,15 @@ export async function startProjectListingDraft(
868
868
  select: { id: true },
869
869
  });
870
870
  const subject = { siteId, subjectType: "project_listing", subjectId: created.id };
871
+ // #8438 D3 — a listing names no magnet slug (ProjectListing carries no
872
+ // link to its flow), so `magnet` would be unprovable: the source is `api`.
871
873
  if (consents) {
872
- await recordConsent({ ...subject, locale, config: consentConfig, consents }, tx);
874
+ await recordConsent(
875
+ { ...subject, personId: null, source: "api", locale, config: consentConfig, consents },
876
+ tx,
877
+ );
873
878
  } else {
874
- await recordLegacyConsent({ ...subject, config: consentConfig }, tx);
879
+ await recordLegacyConsent({ ...subject, source: "api", config: consentConfig }, tx);
875
880
  }
876
881
  return created;
877
882
  });
@@ -352,14 +352,33 @@ export async function resolvePublicContentAdapter(
352
352
  if (site.kind === "not-required" || resolvedAdapterKind() !== "database") {
353
353
  return getContentAdapter();
354
354
  }
355
+ return workspaceContentAdapter(site.workspaceId);
356
+ }
357
+
358
+ /**
359
+ * The content adapter holding ONE workspace's own pages, or `null` when no
360
+ * content is that workspace's (#8514 D9, C13). On the database adapter it is
361
+ * that workspace's database adapter. Off it, the shared content is the
362
+ * deployment's one site — the default workspace's — so only that workspace
363
+ * gets it and every other workspace gets `null`, never the hub's pages
364
+ * (SEC-8514-I). The Admin's plan picker reads its offerings through it.
365
+ */
366
+ export async function workspaceContentAdapter(
367
+ workspaceId: string,
368
+ ): Promise<ContentAdapter | null> {
369
+ if (resolvedAdapterKind() !== "database") {
370
+ // Deferred — see the module-load note in this file's header (#5852 D6).
371
+ const { resolveDefaultWorkspaceId } = await import("./default-tenant.js");
372
+ return (await resolveDefaultWorkspaceId()) === workspaceId ? getContentAdapter() : null;
373
+ }
355
374
 
356
- // The database path, and the only caller that needs apps/api's client. The
375
+ // The database path, and the only one that needs apps/api's client. The
357
376
  // import is deferred for the reason the header gives (#5852 D6), and the
358
377
  // generated client is structurally wider than the narrow read surface the
359
378
  // adapter declares — the cast names that, and nothing else.
360
379
  const { prisma } = await import("../lib/prisma.js");
361
380
  return getContentAdapter({
362
- workspaceId: site.workspaceId,
381
+ workspaceId,
363
382
  prisma: prisma as unknown as ArtifactPrismaClient,
364
383
  });
365
384
  }