create-ailk 0.15.0 → 0.17.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 (128) hide show
  1. package/README.md +124 -0
  2. package/dist/add-module.js +52 -7
  3. package/dist/add-route.js +8 -0
  4. package/dist/adopt.d.ts +74 -0
  5. package/dist/adopt.js +314 -0
  6. package/dist/cli.js +82 -2
  7. package/dist/derive-architecture.d.ts +22 -0
  8. package/dist/derive-architecture.js +58 -1
  9. package/dist/lib/declared-env.d.ts +142 -0
  10. package/dist/lib/declared-env.js +985 -0
  11. package/dist/lib/env-catalog.d.ts +17 -0
  12. package/dist/lib/env-catalog.js +234 -0
  13. package/dist/lib/extract-module-bundle.d.ts +9 -0
  14. package/dist/lib/extract-module-bundle.js +18 -1
  15. package/dist/lib/module-tier.d.ts +2 -0
  16. package/dist/lib/module-tier.js +4 -0
  17. package/dist/module-architecture.js +76 -0
  18. package/dist/parse-args.d.ts +5 -0
  19. package/dist/parse-args.js +17 -1
  20. package/dist/programmatic.d.ts +8 -0
  21. package/dist/programmatic.js +78 -9
  22. package/dist/seed-flows.d.ts +65 -0
  23. package/dist/seed-flows.js +350 -0
  24. package/dist/site-manifest.d.ts +43 -0
  25. package/dist/site-manifest.js +98 -3
  26. package/dist/site-roster.d.ts +13 -0
  27. package/dist/site-roster.js +22 -8
  28. package/dist/spoke-checkout.d.ts +67 -0
  29. package/dist/spoke-checkout.js +562 -0
  30. package/dist/surfaces.js +7 -0
  31. package/dist/sync-routes.js +23 -8
  32. package/dist/topology.d.ts +124 -9
  33. package/dist/topology.js +451 -40
  34. package/package.json +3 -3
  35. package/templates/.env.example +10 -0
  36. package/templates/apps/api/CLAUDE.md +19 -4
  37. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  38. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  39. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  40. package/templates/apps/api/src/bin/spokes.ts +62 -1
  41. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  42. package/templates/apps/api/src/config/index.ts +35 -0
  43. package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
  44. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  45. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  46. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  47. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  48. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  49. package/templates/apps/api/src/lib/spoke-store.ts +25 -3
  50. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  51. package/templates/apps/api/src/middleware/auth.ts +126 -0
  52. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  53. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  54. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
  55. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  56. package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
  57. package/templates/apps/api/src/server.ts +105 -0
  58. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  59. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  60. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
  61. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  62. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  63. package/templates/apps/api/src/services/lead-routing.ts +131 -2
  64. package/templates/apps/api/src/services/person.ts +541 -0
  65. package/templates/apps/web/.env.example +58 -5
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  69. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  70. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  73. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  74. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  75. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  76. package/templates/apps/web/app/[locale]/layout.tsx +28 -15
  77. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  78. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  79. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  80. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  81. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  82. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  83. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +57 -24
  84. package/templates/apps/web/app/openapi.json/route.ts +3 -6
  85. package/templates/apps/web/auth.ts +29 -2
  86. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  87. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  88. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  89. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  90. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  91. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  92. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -0
  93. package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
  94. package/templates/apps/web/lib/account-config.ts +130 -1
  95. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  96. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  97. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  98. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  99. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  100. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  101. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  102. package/templates/apps/web/lib/listings-config.ts +31 -0
  103. package/templates/apps/web/lib/listings.tsx +7 -3
  104. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  105. package/templates/apps/web/lib/next-path.ts +35 -0
  106. package/templates/apps/web/lib/relay.ts +3 -1
  107. package/templates/apps/web/lib/server-api.ts +11 -1
  108. package/templates/apps/web/lib/topology.generated.ts +15 -5
  109. package/templates/apps/web/messages/en.json +32 -3
  110. package/templates/database/CHANGELOG.md +26 -0
  111. package/templates/database/__tests__/agents.db.test.ts +25 -5
  112. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  113. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  114. package/templates/database/__tests__/person.db.test.ts +584 -0
  115. package/templates/database/auth/schema.prisma +3 -2
  116. package/templates/database/config/schema.prisma +13 -0
  117. package/templates/database/inbox/schema.prisma +98 -0
  118. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  119. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  120. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  121. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  122. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  123. package/templates/database/package.json +1 -1
  124. package/templates/database/scripts/person-backfill.sql +132 -0
  125. package/templates/database/tenancy.ts +53 -0
  126. package/templates/tools/check-declared-env.ts +37 -0
  127. package/templates/tools/declared-env.ts +1163 -0
  128. package/versions.json +35 -35
@@ -0,0 +1,541 @@
1
+ /**
2
+ * @file person.ts
3
+ * @description Person — one client of one workspace (#852, spec
4
+ * docs/specs/852-person-the-per-workspace-client-spec.md D3, D5, D6). This
5
+ * file is the ONLY writer of `Person`:
6
+ *
7
+ * - `findOrCreatePerson` — the one creator, called by lead capture
8
+ * (`lead-routing.ts`), the hub's own Stripe checkout webhook (through
9
+ * `recordCheckoutPerson` below), appointment creation
10
+ * (`scheduling/appointments.ts`) and the Admin inbox accept
11
+ * (`inbox-messages.ts`).
12
+ * - `stampPersonUser` — links a Person to a User, only on a verified-email
13
+ * match, never overwriting a link.
14
+ * - `recordCheckoutPerson` — the webhook's step: the Person of a completed
15
+ * hub checkout, with its live-mode customer id.
16
+ * - `resolveMemberPersonId` — the member reads' Person (#7877 D8): the one
17
+ * linked to the caller, else the one whose email is the caller's
18
+ * verified email, linked on first read by `stampPersonUser`'s rule.
19
+ * - `resolveCallerBillingCustomerId` — the customer id the hub vouches for
20
+ * in `GET /v1/hub-sign-in/me` (#7961, spec 7894 D13): that same Person's,
21
+ * only while its `userId` is the caller.
22
+ * - `setPersonPlanOffering` — the plan's only writer (#7877 D8), called by
23
+ * the Admin's plan route and the checkout-lead rule in `lead-routing.ts`.
24
+ *
25
+ * ## Match order (D3) — fixed, and there is no merge
26
+ *
27
+ * 1. Normalize the email (trim ASCII whitespace, lowercase ASCII letters);
28
+ * not storable → `invalid_email`, before any read.
29
+ * 2. A customer id given → the oldest Person of this workspace carrying it.
30
+ * A hit is returned with NO write — its email is never rewritten.
31
+ * 3. The Person of this workspace with this email. A hit gains the customer
32
+ * id only when it has none AND is linked to no user (first writer wins,
33
+ * else `billing: "kept"`), and a name only when it has none. The email is
34
+ * whatever the caller typed, never verified here, so it never pins a
35
+ * customer on a Person whose `userId` the verified-email stamp set
36
+ * (SEC-7894-6, decided on #7961 2026-10-09).
37
+ * 4. No hit → `createMany({ skipDuplicates })` — Postgres INSERT … ON
38
+ * CONFLICT DO NOTHING — then a re-read that finds the winner of any
39
+ * concurrent create. The duplicate is absorbed IN THE DATABASE, never as
40
+ * a caught P2002: the appointment path calls this inside an interactive
41
+ * transaction, where a raised unique violation aborts the transaction
42
+ * (C4). One implementation serves both.
43
+ *
44
+ * ## Why public, and what it may import (D3)
45
+ *
46
+ * The base-tree `lead-routing.ts` imports this file, so it ships in every
47
+ * build and must never reach the paid import closure: it imports
48
+ * `lib/prisma.js`, `verified-email.js` and types only — never
49
+ * `lead-promotion.ts`, `owner-workspaces.ts`, `lead-accept-user.ts` (whose
50
+ * find-or-create shape it mirrors) or `lib/tenant-db.ts`.
51
+ * `packages/create-ailk/__tests__/paid-module-exclusions.test.ts` holds this
52
+ * file and `lead-routing.ts` in `MUST_SHIP`.
53
+ *
54
+ * ## Tenancy
55
+ *
56
+ * Every `Person` statement is scoped by a server-resolved `workspaceId`;
57
+ * there is no list read here (the reads are `tenantDb(tenant).people`).
58
+ * `recordCheckoutPerson`'s two other reads are by a server-minted
59
+ * `FlowCheckout` id from a signature-verified webhook, matched on that
60
+ * session's own `stripeSessionId`, then that row's lead scoped by the row's
61
+ * own `workspaceId`. `resolveMemberPersonId` reads by `(workspaceId, userId)`
62
+ * and by `(workspaceId, email)`, and `resolveCallerBillingCustomerId` by
63
+ * `(id, workspaceId, userId)`, the workspace server-resolved and the user
64
+ * the authenticated caller. `architecture.yaml` names this file as an
65
+ * exclusion with that reason.
66
+ *
67
+ * ## Errors and logs
68
+ *
69
+ * A store error is caught and returned as a bare kind; no Prisma message is
70
+ * returned or logged (SEC-006). These functions log nothing: each caller
71
+ * logs the outcome kind, never an email, a name or a customer id. The
72
+ * exceptions are `resolveMemberPersonId`, whose `null` hides a refused stamp,
73
+ * and `resolveCallerBillingCustomerId`, whose `null` hides a failed read: each
74
+ * logs that outcome's kind through the logger its caller passes.
75
+ */
76
+
77
+ import type { Prisma } from "@working-theory/database";
78
+ import type { Id } from "@working-theory/ids";
79
+
80
+ import { prisma } from "../lib/prisma.js";
81
+
82
+ import { findVerifiedEmail } from "./verified-email.js";
83
+
84
+ // ─── Normalization ────────────────────────────────────────────────────────────
85
+
86
+ /** The ASCII whitespace the backfill's btrim set names: SP, HT, LF, CR, FF, VT. */
87
+ const EDGE_WHITESPACE = /^[ \t\n\r\f\v]+|[ \t\n\r\f\v]+$/g;
88
+ /** Printable ASCII, one `@`, a non-empty side each — the `people_email_normalized` CHECK. */
89
+ const STORABLE_EMAIL = /^[!-?A-~]+@[!-?A-~]+$/;
90
+ const MAX_EMAIL_LENGTH = 254;
91
+ const MAX_NAME_CODE_POINTS = 200;
92
+ const MAX_CUSTOMER_ID_LENGTH = 255;
93
+ /** C0 and C1 controls and the bidi controls, removed from a name (SEC-852-I). */
94
+ // eslint-disable-next-line no-control-regex -- the control-character range is exactly what a stored name must not carry.
95
+ const NAME_STRIP = /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
96
+
97
+ /**
98
+ * Trim ASCII whitespace, then lowercase ASCII letters only — the same value
99
+ * Postgres computes as `lower(btrim(x, E' \t\n\r\f\x0B') COLLATE "C")`.
100
+ */
101
+ export function normalizePersonEmail(raw: string): string {
102
+ return raw
103
+ .replace(EDGE_WHITESPACE, "")
104
+ .replace(/[A-Z]/g, (c) => c.toLowerCase());
105
+ }
106
+
107
+ /**
108
+ * Printable ASCII, one `@` with a non-empty side each, at most 254
109
+ * characters — the `people_email_normalized` CHECK's regex. ASCII only, so
110
+ * the TypeScript length and the Postgres length agree.
111
+ */
112
+ export function isStorablePersonEmail(email: string): boolean {
113
+ return email.length <= MAX_EMAIL_LENGTH && STORABLE_EMAIL.test(email);
114
+ }
115
+
116
+ /**
117
+ * A name as stored: controls and bidi controls removed, trimmed of spaces, cut
118
+ * to 200 code points; empty → `null`.
119
+ */
120
+ export function normalizePersonName(raw: unknown): string | null {
121
+ if (typeof raw !== "string") return null;
122
+ // Spaces only, as the backfill's `btrim(x)` trims, so a live-written
123
+ // name and a backfilled one agree.
124
+ const cleaned = Array.from(raw.replace(NAME_STRIP, "").replace(/^ +| +$/g, ""))
125
+ .slice(0, MAX_NAME_CODE_POINTS)
126
+ .join("");
127
+ return cleaned === "" ? null : cleaned;
128
+ }
129
+
130
+ function usableCustomerId(raw: string | null | undefined): string | null {
131
+ return typeof raw === "string" &&
132
+ raw.length > 0 &&
133
+ raw.length <= MAX_CUSTOMER_ID_LENGTH
134
+ ? raw
135
+ : null;
136
+ }
137
+
138
+ // ─── findOrCreatePerson ───────────────────────────────────────────────────────
139
+
140
+ /** The one delegate this file writes through — the shared client or a transaction's. */
141
+ export type PersonDb = Pick<Prisma.TransactionClient, "person">;
142
+
143
+ export type FindOrCreatePersonResult =
144
+ | {
145
+ ok: true;
146
+ value: {
147
+ personId: Id<"person">;
148
+ created: boolean;
149
+ billing: "written" | "kept" | "unchanged";
150
+ };
151
+ }
152
+ | { ok: false; error: { kind: "invalid_email" | "internal_error" } };
153
+
154
+ type MatchedPerson = {
155
+ id: string;
156
+ name: string | null;
157
+ userId: string | null;
158
+ billingCustomerId: string | null;
159
+ };
160
+
161
+ const MATCH_SELECT = {
162
+ id: true,
163
+ name: true,
164
+ userId: true,
165
+ billingCustomerId: true,
166
+ } as const;
167
+
168
+ /**
169
+ * Step 3's writes on an email match: the customer id only when the Person
170
+ * has none and is linked to no user, the name only when it has none — each
171
+ * one guarded `updateMany`, so a concurrent writer's value, or a stamp that
172
+ * lands between the read and the write, is never overwritten or bypassed.
173
+ */
174
+ async function enrichMatched(
175
+ db: PersonDb,
176
+ workspaceId: string,
177
+ person: MatchedPerson,
178
+ name: string | null,
179
+ customerId: string | null,
180
+ ): Promise<"written" | "kept" | "unchanged"> {
181
+ let billing: "written" | "kept" | "unchanged" = "unchanged";
182
+ if (customerId !== null) {
183
+ if (person.billingCustomerId !== null || person.userId !== null) {
184
+ billing = "kept";
185
+ } else {
186
+ const written = await db.person.updateMany({
187
+ where: { id: person.id, workspaceId, userId: null, billingCustomerId: null },
188
+ data: { billingProvider: "stripe", billingCustomerId: customerId },
189
+ });
190
+ billing = written.count === 1 ? "written" : "kept";
191
+ }
192
+ }
193
+ if (name !== null && person.name === null) {
194
+ await db.person.updateMany({
195
+ where: { id: person.id, workspaceId, name: null },
196
+ data: { name },
197
+ });
198
+ }
199
+ return billing;
200
+ }
201
+
202
+ /**
203
+ * Find this workspace's Person for an email, or create it.
204
+ *
205
+ * @param workspaceId the SERVER-RESOLVED workspace id — never a request value.
206
+ * @param input.email the raw address; normalized here.
207
+ * @param input.name an optional display name, from anonymous text.
208
+ * @param input.billingCustomerId an optional provider customer id.
209
+ * @param db the shared client by default; the appointment path passes its
210
+ * transaction client so the step runs on the lock's own connection.
211
+ */
212
+ export async function findOrCreatePerson(
213
+ workspaceId: string,
214
+ input: {
215
+ email: string;
216
+ name?: string | null;
217
+ billingCustomerId?: string | null;
218
+ },
219
+ db: PersonDb = prisma,
220
+ ): Promise<FindOrCreatePersonResult> {
221
+ const email =
222
+ typeof input.email === "string" ? normalizePersonEmail(input.email) : "";
223
+ if (!isStorablePersonEmail(email)) {
224
+ return { ok: false, error: { kind: "invalid_email" } };
225
+ }
226
+ const name = normalizePersonName(input.name);
227
+ const customerId = usableCustomerId(input.billingCustomerId);
228
+
229
+ try {
230
+ // Step 2 — a customer id names its Person; nothing is written.
231
+ if (customerId !== null) {
232
+ const byCustomer = await db.person.findFirst({
233
+ where: { workspaceId, billingCustomerId: customerId },
234
+ orderBy: [{ createdAt: "asc" }, { id: "asc" }],
235
+ select: { id: true },
236
+ });
237
+ if (byCustomer) {
238
+ return {
239
+ ok: true,
240
+ value: {
241
+ personId: byCustomer.id as Id<"person">,
242
+ created: false,
243
+ billing: "unchanged",
244
+ },
245
+ };
246
+ }
247
+ }
248
+
249
+ // Step 3 — the email's Person.
250
+ const byEmail = await db.person.findFirst({
251
+ where: { workspaceId, email },
252
+ select: MATCH_SELECT,
253
+ });
254
+ if (byEmail) {
255
+ const billing = await enrichMatched(db, workspaceId, byEmail, name, customerId);
256
+ return {
257
+ ok: true,
258
+ value: { personId: byEmail.id as Id<"person">, created: false, billing },
259
+ };
260
+ }
261
+
262
+ // Step 4 — insert, absorbing a concurrent duplicate in the database.
263
+ const inserted = await db.person.createMany({
264
+ data: [
265
+ {
266
+ workspaceId,
267
+ email,
268
+ name,
269
+ ...(customerId !== null && {
270
+ billingProvider: "stripe",
271
+ billingCustomerId: customerId,
272
+ }),
273
+ },
274
+ ],
275
+ skipDuplicates: true,
276
+ });
277
+ const winner = await db.person.findFirst({
278
+ where: { workspaceId, email },
279
+ select: MATCH_SELECT,
280
+ });
281
+ if (!winner) return { ok: false, error: { kind: "internal_error" } };
282
+ if (inserted.count === 1) {
283
+ return {
284
+ ok: true,
285
+ value: {
286
+ personId: winner.id as Id<"person">,
287
+ created: true,
288
+ billing: customerId !== null ? "written" : "unchanged",
289
+ },
290
+ };
291
+ }
292
+ // A concurrent create won the race: treat its row as an email match.
293
+ const billing = await enrichMatched(db, workspaceId, winner, name, customerId);
294
+ return {
295
+ ok: true,
296
+ value: { personId: winner.id as Id<"person">, created: false, billing },
297
+ };
298
+ } catch {
299
+ return { ok: false, error: { kind: "internal_error" } };
300
+ }
301
+ }
302
+
303
+ // ─── stampPersonUser (D5) ─────────────────────────────────────────────────────
304
+
305
+ export type StampPersonUserOutcome =
306
+ | "stamped"
307
+ | "already_linked"
308
+ | "user_unverified"
309
+ | "email_mismatch"
310
+ | "linked_to_other_user"
311
+ | "user_on_other_person"
312
+ | "not_found"
313
+ | "internal_error";
314
+
315
+ function isUniqueViolation(err: unknown): boolean {
316
+ return (
317
+ typeof err === "object" &&
318
+ err !== null &&
319
+ (err as { code?: unknown }).code === "P2002"
320
+ );
321
+ }
322
+
323
+ /**
324
+ * Link a Person to a User — only when the User's VERIFIED email normalizes
325
+ * to the Person's (`verified-email.ts`'s rule), and never over an existing
326
+ * link. Creating a Person never calls this: no verified email is needed to
327
+ * exist. Runs outside any transaction, so catching a P2002 is safe here.
328
+ */
329
+ export async function stampPersonUser(
330
+ workspaceId: string,
331
+ personId: string,
332
+ userId: string,
333
+ ): Promise<StampPersonUserOutcome> {
334
+ try {
335
+ const person = await prisma.person.findFirst({
336
+ where: { id: personId, workspaceId },
337
+ select: { email: true, userId: true },
338
+ });
339
+ if (!person) return "not_found";
340
+ if (person.userId === userId) return "already_linked";
341
+ if (person.userId !== null) return "linked_to_other_user";
342
+
343
+ const verified = await findVerifiedEmail(userId);
344
+ if (!verified.ok) return "user_unverified";
345
+ if (normalizePersonEmail(verified.value.email) !== person.email) {
346
+ return "email_mismatch";
347
+ }
348
+
349
+ let count: number;
350
+ try {
351
+ ({ count } = await prisma.person.updateMany({
352
+ where: { id: personId, workspaceId, userId: null },
353
+ data: { userId },
354
+ }));
355
+ } catch (err) {
356
+ if (isUniqueViolation(err)) return "user_on_other_person";
357
+ throw err;
358
+ }
359
+ if (count === 1) return "stamped";
360
+
361
+ const reread = await prisma.person.findFirst({
362
+ where: { id: personId, workspaceId },
363
+ select: { userId: true },
364
+ });
365
+ if (!reread) return "not_found";
366
+ return reread.userId === userId ? "already_linked" : "linked_to_other_user";
367
+ } catch {
368
+ return "internal_error";
369
+ }
370
+ }
371
+
372
+ // ─── recordCheckoutPerson (D6) ────────────────────────────────────────────────
373
+
374
+ export type CheckoutPersonOutcome =
375
+ | "person_written"
376
+ | "person_kept"
377
+ | "person_unchanged"
378
+ | "person_skipped"
379
+ | "person_failed";
380
+
381
+ /**
382
+ * The hub webhook's Person step for a paid `checkout.session.completed` of
383
+ * its OWN site carrying `metadata.flowCheckoutId`.
384
+ *
385
+ * Reads the checkout row THIS session completed (`stripeSessionId` must be
386
+ * this session's id and the row `completed`), so a session that merely names
387
+ * another checkout's id matches nothing (SEC-852-H); then that row's lead,
388
+ * scoped by the row's workspace. The customer id's one source is
389
+ * `session.customer`, and it is passed only for a LIVE-mode event: a
390
+ * test-mode checkout typed with someone else's email cannot pin a customer
391
+ * id on their Person (SEC-852-A). Never throws.
392
+ */
393
+ export async function recordCheckoutPerson(input: {
394
+ flowCheckoutId: string;
395
+ stripeSessionId: string | null | undefined;
396
+ customer: unknown;
397
+ livemode: boolean;
398
+ }): Promise<CheckoutPersonOutcome> {
399
+ if (typeof input.stripeSessionId !== "string" || input.stripeSessionId === "") {
400
+ return "person_skipped";
401
+ }
402
+ try {
403
+ const checkout = await prisma.flowCheckout.findFirst({
404
+ where: {
405
+ id: input.flowCheckoutId,
406
+ stripeSessionId: input.stripeSessionId,
407
+ status: "completed",
408
+ },
409
+ select: { leadId: true, workspaceId: true },
410
+ });
411
+ if (!checkout) return "person_skipped";
412
+
413
+ const lead = await prisma.lead.findFirst({
414
+ where: { id: checkout.leadId, workspaceId: checkout.workspaceId },
415
+ select: { email: true },
416
+ });
417
+ if (!lead) return "person_skipped";
418
+
419
+ const customerId =
420
+ input.livemode === true && typeof input.customer === "string"
421
+ ? input.customer
422
+ : undefined;
423
+ const result = await findOrCreatePerson(checkout.workspaceId, {
424
+ email: lead.email,
425
+ ...(customerId !== undefined && { billingCustomerId: customerId }),
426
+ });
427
+ if (!result.ok) {
428
+ return result.error.kind === "invalid_email" ? "person_skipped" : "person_failed";
429
+ }
430
+ switch (result.value.billing) {
431
+ case "written":
432
+ return "person_written";
433
+ case "kept":
434
+ return "person_kept";
435
+ default:
436
+ return "person_unchanged";
437
+ }
438
+ } catch {
439
+ return "person_failed";
440
+ }
441
+ }
442
+
443
+ // ─── The member area (#7877 D8) ───────────────────────────────────────────────
444
+
445
+ /**
446
+ * The Person a member read answers for: this workspace's Person linked to
447
+ * the caller, else the one whose email is the caller's VERIFIED email —
448
+ * linked now through `stampPersonUser` (its rule, unchanged) and the answer
449
+ * only on `stamped` or `already_linked`. `null` is "not a client here";
450
+ * every other stamp outcome, and a store error, is `null` too, logged by its
451
+ * kind only. Never throws.
452
+ *
453
+ * @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
454
+ * @param userId the authenticated caller.
455
+ * @param verifiedEmail the caller's verified address, read by the caller.
456
+ */
457
+ export async function resolveMemberPersonId(
458
+ workspaceId: string,
459
+ userId: string,
460
+ verifiedEmail: string,
461
+ log?: { warn: (obj: object, msg: string) => void },
462
+ ): Promise<string | null> {
463
+ let byEmail: { id: string } | null;
464
+ try {
465
+ const linked = await prisma.person.findFirst({
466
+ where: { workspaceId, userId },
467
+ select: { id: true },
468
+ });
469
+ if (linked) return linked.id;
470
+
471
+ byEmail = await prisma.person.findFirst({
472
+ where: { workspaceId, email: normalizePersonEmail(verifiedEmail) },
473
+ select: { id: true },
474
+ });
475
+ } catch {
476
+ // A store error is "not a client here" for this read: never thrown.
477
+ log?.warn({ event: "member-person-stamp", outcome: "internal_error" }, "[person] member read failed");
478
+ return null;
479
+ }
480
+ if (!byEmail) return null;
481
+
482
+ const outcome = await stampPersonUser(workspaceId, byEmail.id, userId);
483
+ if (outcome === "stamped" || outcome === "already_linked") return byEmail.id;
484
+ log?.warn({ event: "member-person-stamp", outcome }, "[person] member link refused");
485
+ return null;
486
+ }
487
+
488
+ /**
489
+ * The customer id the hub vouches for to a spoke's buy route (#7961, spec
490
+ * 7894 D13): the `billingCustomerId` of the Person `resolveMemberPersonId`
491
+ * answers for (workspace, caller), read only while that Person's `userId` is
492
+ * the caller — never a Person matched by email alone (SEC-7894-6). `null` is
493
+ * "no customer to reuse"; a store error is `null` too, logged by its kind
494
+ * only. Never throws.
495
+ *
496
+ * @param workspaceId the SERVER-RESOLVED workspace (the calling spoke's).
497
+ * @param userId the authenticated caller.
498
+ * @param verifiedEmail the caller's verified address, read by the caller.
499
+ */
500
+ export async function resolveCallerBillingCustomerId(
501
+ workspaceId: string,
502
+ userId: string,
503
+ verifiedEmail: string,
504
+ log?: { warn: (obj: object, msg: string) => void },
505
+ ): Promise<string | null> {
506
+ const personId = await resolveMemberPersonId(workspaceId, userId, verifiedEmail, log);
507
+ if (personId === null) return null;
508
+ try {
509
+ const person = await prisma.person.findFirst({
510
+ where: { id: personId, workspaceId, userId },
511
+ select: { billingCustomerId: true },
512
+ });
513
+ return person?.billingCustomerId ?? null;
514
+ } catch {
515
+ log?.warn({ event: "buyer-customer-read", outcome: "internal_error" }, "[person] buyer read failed");
516
+ return null;
517
+ }
518
+ }
519
+
520
+ export type SetPlanOfferingOutcome = "set" | "not_found" | "internal_error";
521
+
522
+ /**
523
+ * Set — or, with `null`, clear — a Person's plan: the ONE writer of
524
+ * `Person.planOffering`. One `updateMany` scoped by the server-resolved
525
+ * workspace; no row → `not_found`. Never throws.
526
+ */
527
+ export async function setPersonPlanOffering(
528
+ workspaceId: string,
529
+ personId: string,
530
+ planOffering: string | null,
531
+ ): Promise<SetPlanOfferingOutcome> {
532
+ try {
533
+ const { count } = await prisma.person.updateMany({
534
+ where: { id: personId, workspaceId },
535
+ data: { planOffering },
536
+ });
537
+ return count === 0 ? "not_found" : "set";
538
+ } catch {
539
+ return "internal_error";
540
+ }
541
+ }
@@ -93,17 +93,26 @@ CONTENT_ADAPTER=mdx
93
93
  # NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
94
94
 
95
95
  # =============================================================================
96
- # Listicle directory (#7521) — server-side only, never NEXT_PUBLIC_
96
+ # Listicle directory (#7521) — switched on by the site's listing key (#7872)
97
97
  # =============================================================================
98
98
  # The site's public listing key: the SAME value apps/api reads as
99
99
  # AILK_LISTING_PUBLIC_KEY (single-tenant) or holds as a SiteListingConfig
100
100
  # publicKey row (multi-tenant). Public by design — it names a site, it grants
101
- # nothing — but it is read on the server only. When it is set and the paid
102
- # @working-theory/ui-signal-collectors package is installed, the page a live
103
- # listicle names as its landing page shows that listicle's accepted entries,
104
- # and each entry gets a page at /<landingPage>/<slug>. Unset, no page reads
101
+ # nothing. When it is set and the paid @working-theory/ui-signal-collectors
102
+ # package is installed, the page a live listicle names as its landing page
103
+ # shows that listicle's accepted entries, and each entry gets a page at
104
+ # /<landingPage>/<slug>. Unset, the listings module is off and no page reads
105
105
  # the directory. One web deployment is one site: a hub's spokes are separate
106
106
  # web deployments, each with its own key.
107
+ #
108
+ # Two names, one reader (lib/listings-config.ts):
109
+ # 1. NEXT_PUBLIC_AILK_LISTING_SITE_KEY wins when set. It is inlined into the
110
+ # build, so changing or rotating it needs a rebuild — and while it is set,
111
+ # a runtime change to AILK_LISTING_PUBLIC_KEY has no effect.
112
+ # 2. AILK_LISTING_PUBLIC_KEY is the fallback (server-read), so a site
113
+ # configured before this name existed keeps its directory on upgrade.
114
+ # An empty value counts as unset.
115
+ # NEXT_PUBLIC_AILK_LISTING_SITE_KEY=
107
116
  # AILK_LISTING_PUBLIC_KEY=
108
117
 
109
118
  # =============================================================================
@@ -191,6 +200,36 @@ CONTENT_ADAPTER=mdx
191
200
  #
192
201
  # AILK_AUTH_TRUSTED_PROXIES=
193
202
 
203
+ # =============================================================================
204
+ # Sign-in through the hub — a spoke scaffolded with --hub-sign-in (#7894)
205
+ # =============================================================================
206
+ # A spoke has no database and no Better Auth of its own. With --hub-sign-in, a
207
+ # visitor signs in on the HUB's own sign-in page and returns to this site's
208
+ # /account; the hub-issued credential lives in a sealed, HttpOnly cookie. All
209
+ # four are server-only — never rename one with a NEXT_PUBLIC_ prefix — and all
210
+ # four must be set, or /account says sign-in is not available yet. The routes
211
+ # under /api/hub-session answer 404 on any site not scaffolded in this mode.
212
+ #
213
+ # The hub's WEB origin (where its /sign-in lives), https: or http: on
214
+ # localhost. create-ailk writes it to .env.local.
215
+ # AILK_HUB_SIGN_IN_URL=https://hub.example.com
216
+ #
217
+ # This site's client id and secret, issued on the hub with
218
+ # pnpm --filter @working-theory/api spokes sign-in-client --id <spoke_id> \
219
+ # --redirect-uri <this site's origin>/api/hub-session/callback
220
+ # The client id is the spoke's id. The secret is shown once; keep it out of git.
221
+ # AILK_HUB_CLIENT_ID=
222
+ # AILK_HUB_CLIENT_SECRET=
223
+ #
224
+ # This site's own cookie key material: at least 32 characters, generated per
225
+ # site (create-ailk writes one to .env.local; `openssl rand -base64 32` makes
226
+ # another). Never the client secret. Changing it signs every visitor out.
227
+ # AILK_HUB_SESSION_SECRET=
228
+ #
229
+ # The redirect URI is built from this site's NEXT_PUBLIC_BASE_URL (else the
230
+ # baseUrl in content/_site.mdx) — never from a request's Host — so set it to
231
+ # the origin you registered on the hub.
232
+
194
233
  # =============================================================================
195
234
  # Observability
196
235
  # =============================================================================
@@ -228,3 +267,17 @@ CONTENT_ADAPTER=mdx
228
267
  # (see the relay block above, #7644), so the baseline CSP's
229
268
  # `connect-src 'self'` already covers them — no CSP extension is needed for
230
269
  # them, whatever NEXT_PUBLIC_AILK_API_URL is set to.
270
+ #
271
+ # On a SPOKE (create-ailk --topology spoke, #7872) this site has no apps/api:
272
+ # /meet lists the meeting types the hub stores for this spoke's workspace and
273
+ # books through the hub at AILK_API_URL with AILK_SPOKE_KEY. Then:
274
+ # - the FIRST segment (the owner) must name the provider account the hub
275
+ # uses for this spoke's workspace (its scheduling connection). It composes
276
+ # each booker's calLink; it is not an access control — the hub checks a
277
+ # spoke booking's slug, never its owner.
278
+ # - the SECOND segment is ignored: a workspace with no stored meeting-type
279
+ # list shows no meetings, because the hub books only a slug it stores.
280
+ # - the booker's two calls count against the relay's per-visitor budget,
281
+ # which holds per visitor only behind an edge that overwrites
282
+ # x-forwarded-for and x-real-ip (Vercel does); elsewhere every visitor
283
+ # shares one budget.