create-ailk 0.19.0 → 0.19.1

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 (91) hide show
  1. package/dist/add-module.js +7 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  5. package/dist/lib/scaffold-render-sweep.js +52 -0
  6. package/dist/module-architecture.js +9 -3
  7. package/dist/programmatic.js +8 -1
  8. package/dist/site-roster.js +21 -4
  9. package/dist/surfaces.d.ts +14 -0
  10. package/dist/surfaces.js +31 -1
  11. package/dist/topology.js +29 -1
  12. package/package.json +3 -3
  13. package/templates/apps/api/.env.example +17 -1
  14. package/templates/apps/api/CLAUDE.md +10 -4
  15. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  16. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  17. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  18. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  19. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  20. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  21. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  22. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  23. package/templates/apps/api/src/lib/stripe.ts +97 -5
  24. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
  25. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  26. package/templates/apps/api/src/middleware/auth.ts +11 -3
  27. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  28. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  29. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  30. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  31. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  32. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  33. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  34. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  35. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  36. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  37. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  38. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  39. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  40. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  41. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  42. package/templates/apps/api/src/server.ts +111 -16
  43. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  44. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  45. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  46. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  47. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  48. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  49. package/templates/apps/api/src/services/consent.ts +488 -3
  50. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  51. package/templates/apps/api/src/services/person.ts +501 -1
  52. package/templates/apps/api/src/services/project-listings.ts +7 -2
  53. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  54. package/templates/apps/api/src/services/revalidation.ts +11 -1
  55. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  57. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
  58. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +122 -18
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +55 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  65. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
  66. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  67. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +66 -4
  69. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  70. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
  71. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  72. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +41 -33
  75. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  76. package/templates/apps/web/auth.ts +20 -1
  77. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  78. package/templates/apps/web/lib/auth-email.ts +45 -10
  79. package/templates/apps/web/lib/topology.generated.ts +9 -0
  80. package/templates/apps/web/messages/en.json +54 -14
  81. package/templates/database/CHANGELOG.md +16 -0
  82. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  83. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  84. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  85. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  86. package/templates/database/__tests__/person.db.test.ts +3 -1
  87. package/templates/database/inbox/schema.prisma +76 -10
  88. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  89. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  90. package/templates/database/package.json +1 -1
  91. package/versions.json +35 -35
@@ -0,0 +1,335 @@
1
+ /**
2
+ * The consent ledger's read boundary (#8438, spec
3
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md D4).
4
+ *
5
+ * `consent_grants` is append-only and a Person's current state is the newest
6
+ * row per purpose, so every reader here asks the database for exactly that.
7
+ * Nothing is read through a window: `DISTINCT ON (purpose_id) … ORDER BY
8
+ * purpose_id, created_at DESC, id DESC` is one indexed pass over ALL the
9
+ * matching rows, so no number of other rows can push an answer out
10
+ * (SEC-8438-2).
11
+ *
12
+ * TWO READINGS of a Person's rows (SEC-8438-1). A Person is keyed on an email
13
+ * anyone can type into a form, so a row an anonymous writer attached to it
14
+ * proves nothing about what the person themself chose:
15
+ *
16
+ * - OWN rows: `person_id = P` and a source only a verified caller or a
17
+ * signed link sent to the person can write — `sign_up`,
18
+ * `account_settings`, `email_unsubscribe` (`OWN_CONSENT_SOURCES`). The
19
+ * member's screens, the sign-up prompt, and every future send read only
20
+ * these.
21
+ * - ANY-SOURCE rows: every row with `person_id = P`, plus the cookie rows
22
+ * of every visitor linked to P. The operator's Person page reads these,
23
+ * marked `unverified` when the source proves nothing.
24
+ *
25
+ * Every query takes an explicit `workspaceId`, and a join to a linked
26
+ * visitor's rows matches `workspace_id` on both tables (SEC-8438-11) — a
27
+ * visitor id is client-chosen and not namespaced, so another tenant's rows
28
+ * under the same id must never join.
29
+ *
30
+ * This file is the ONLY non-test reader of `consent_grants` and
31
+ * `consent_visitor_links` (`architecture.yaml` names it as a data-access
32
+ * boundary, the kind `member-store.ts` is). The writes are
33
+ * `services/consent.ts`. Logs carry nothing: no email, visitor id, token or
34
+ * statement.
35
+ */
36
+ import type { Prisma } from "@working-theory/database";
37
+ import {
38
+ consentPurposeKind,
39
+ type ConsentPurposeKind,
40
+ type ConsentSource,
41
+ type SiteConsentConfig,
42
+ } from "@working-theory/validation";
43
+
44
+ import { prisma } from "./prisma.js";
45
+
46
+ /** What every reader needs of a client: raw reads only. */
47
+ export type ConsentReadClient = Pick<Prisma.TransactionClient, "$queryRaw">;
48
+
49
+ /**
50
+ * The sources only a verified caller or a signed link can write (D4,
51
+ * SEC-8438-1). The SQL below names the same three literals; the store's test
52
+ * asserts the two agree.
53
+ */
54
+ export const OWN_CONSENT_SOURCES = [
55
+ "sign_up",
56
+ "account_settings",
57
+ "email_unsubscribe",
58
+ ] as const satisfies readonly ConsentSource[];
59
+
60
+ /** The sources whose rows prove nothing the client confirmed themself. */
61
+ const UNVERIFIED_SOURCES: ReadonlySet<ConsentSource> = new Set([
62
+ "magnet",
63
+ "api",
64
+ "cookie_banner",
65
+ "legacy",
66
+ ]);
67
+
68
+ /** Whether a row from `source` is unconfirmed by the client (D4). */
69
+ export function isUnverifiedSource(source: ConsentSource): boolean {
70
+ return UNVERIFIED_SOURCES.has(source);
71
+ }
72
+
73
+ /** A history page's size (D7). */
74
+ export const CONSENT_HISTORY_PAGE_SIZE = 500;
75
+
76
+ /** The most platform rows one user can have in view (D4). */
77
+ const PLATFORM_ROWS_MAX = 100;
78
+
79
+ /** The newest ledger row for one purpose, as the readers serve it. */
80
+ export interface ConsentStateRow {
81
+ id: string;
82
+ purposeId: string;
83
+ granted: boolean;
84
+ at: Date;
85
+ source: ConsentSource;
86
+ sourceRef: string | null;
87
+ policyVersion: number | null;
88
+ }
89
+
90
+ export interface ConsentHistoryRow extends ConsentStateRow {
91
+ locale: string;
92
+ statement: string;
93
+ subjectType: string;
94
+ }
95
+
96
+ /** A registry purpose with the newest row read for it and what that means now. */
97
+ export interface PurposeConsentState {
98
+ purposeId: string;
99
+ kind: ConsentPurposeKind;
100
+ row: ConsentStateRow | null;
101
+ /** Granted now: the row's answer, else on for a preference and off otherwise. */
102
+ effective: boolean;
103
+ }
104
+
105
+ /** The effective answer for a purpose given its newest row (D4). */
106
+ export function effectiveConsent(
107
+ kind: ConsentPurposeKind,
108
+ row: { granted: boolean } | null,
109
+ ): boolean {
110
+ if (row !== null) return row.granted;
111
+ return kind === "preference";
112
+ }
113
+
114
+ /** Fold the newest row per purpose id into the registry's purposes. */
115
+ export function foldRowsIntoPurposes(
116
+ config: SiteConsentConfig,
117
+ rows: readonly ConsentStateRow[],
118
+ ): PurposeConsentState[] {
119
+ const byPurpose = new Map(rows.map((row) => [row.purposeId, row]));
120
+ return config.purposes.map((purpose) => {
121
+ const kind = consentPurposeKind(purpose);
122
+ const row = byPurpose.get(purpose.id) ?? null;
123
+ return {
124
+ purposeId: purpose.id,
125
+ kind,
126
+ row,
127
+ effective: effectiveConsent(kind, row),
128
+ };
129
+ });
130
+ }
131
+
132
+ /**
133
+ * The newest OWN row per purpose id for a Person, unfolded (D4): one indexed
134
+ * `DISTINCT ON` pass over all own rows, never a window.
135
+ */
136
+ export async function ownConsentRows(
137
+ workspaceId: string,
138
+ personId: string,
139
+ db: ConsentReadClient = prisma,
140
+ ): Promise<ConsentStateRow[]> {
141
+ return db.$queryRaw<ConsentStateRow[]>`
142
+ SELECT DISTINCT ON (g."purpose_id")
143
+ g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
144
+ g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
145
+ FROM "consent_grants" g
146
+ WHERE g."workspace_id" = ${workspaceId}
147
+ AND g."person_id" = ${personId}
148
+ AND g."source" IN ('sign_up', 'account_settings', 'email_unsubscribe')
149
+ ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
150
+ }
151
+
152
+ /**
153
+ * The newest ANY-SOURCE row per purpose id for a Person: `person_id = P` or a
154
+ * cookie row of a visitor linked to P, in this workspace on both tables.
155
+ * Includes purposes the registry no longer declares — the operator still sees
156
+ * what was recorded.
157
+ */
158
+ export async function anySourceConsentRows(
159
+ workspaceId: string,
160
+ personId: string,
161
+ db: ConsentReadClient = prisma,
162
+ ): Promise<ConsentStateRow[]> {
163
+ return db.$queryRaw<ConsentStateRow[]>`
164
+ SELECT DISTINCT ON (g."purpose_id")
165
+ g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
166
+ g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
167
+ FROM "consent_grants" g
168
+ WHERE g."workspace_id" = ${workspaceId}
169
+ AND (
170
+ g."person_id" = ${personId}
171
+ OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
172
+ SELECT l."visitor_id" FROM "consent_visitor_links" l
173
+ WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
174
+ ))
175
+ )
176
+ ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC`;
177
+ }
178
+
179
+ /**
180
+ * A Person's OWN reading, per registry purpose (D4). The member's screens and
181
+ * the sign-up prompt read only this.
182
+ */
183
+ export async function ownConsentState(
184
+ workspaceId: string,
185
+ personId: string,
186
+ config: SiteConsentConfig,
187
+ db: ConsentReadClient = prisma,
188
+ ): Promise<PurposeConsentState[]> {
189
+ return foldRowsIntoPurposes(
190
+ config,
191
+ await ownConsentRows(workspaceId, personId, db),
192
+ );
193
+ }
194
+
195
+ /**
196
+ * A Person's ANY-SOURCE reading, per registry purpose. The operator's Person
197
+ * page and the unsubscribe dedupe read it; a row here is never a member's own
198
+ * answer (D4).
199
+ */
200
+ export async function anySourceConsentState(
201
+ workspaceId: string,
202
+ personId: string,
203
+ config: SiteConsentConfig,
204
+ db: ConsentReadClient = prisma,
205
+ ): Promise<PurposeConsentState[]> {
206
+ return foldRowsIntoPurposes(
207
+ config,
208
+ await anySourceConsentRows(workspaceId, personId, db),
209
+ );
210
+ }
211
+
212
+ /** One page of a Person's any-source history, newest first. */
213
+ export interface ConsentHistoryPage {
214
+ rows: ConsentHistoryRow[];
215
+ /** The id of the page's last row when another page follows, else `null`. */
216
+ nextCursor: string | null;
217
+ }
218
+
219
+ /**
220
+ * A Person's any-source history, 500 rows a page, newest first, keyed by
221
+ * `(created_at, id)`. Every page is complete: nothing is truncated (D7).
222
+ * Returns `"invalid_cursor"` for a cursor that is not one of this Person's
223
+ * rows — checked inside the Person's own scope, so a row id of another
224
+ * Person or workspace reads exactly like an unknown one.
225
+ */
226
+ export async function personConsentHistory(
227
+ workspaceId: string,
228
+ personId: string,
229
+ cursor: string | undefined,
230
+ db: ConsentReadClient = prisma,
231
+ ): Promise<ConsentHistoryPage | "invalid_cursor"> {
232
+ if (cursor !== undefined) {
233
+ const found = await db.$queryRaw<Array<{ id: string }>>`
234
+ SELECT g."id" FROM "consent_grants" g
235
+ WHERE g."workspace_id" = ${workspaceId}
236
+ AND g."id" = ${cursor}
237
+ AND (
238
+ g."person_id" = ${personId}
239
+ OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
240
+ SELECT l."visitor_id" FROM "consent_visitor_links" l
241
+ WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
242
+ ))
243
+ )
244
+ LIMIT 1`;
245
+ if (found.length === 0) return "invalid_cursor";
246
+ }
247
+ const cursorId = cursor ?? null;
248
+ const rows = await db.$queryRaw<ConsentHistoryRow[]>`
249
+ SELECT g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
250
+ g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion",
251
+ g."locale", g."statement", g."subject_type" AS "subjectType"
252
+ FROM "consent_grants" g
253
+ WHERE g."workspace_id" = ${workspaceId}
254
+ AND (
255
+ g."person_id" = ${personId}
256
+ OR (g."subject_type" = 'visitor' AND g."subject_id" IN (
257
+ SELECT l."visitor_id" FROM "consent_visitor_links" l
258
+ WHERE l."workspace_id" = ${workspaceId} AND l."person_id" = ${personId}
259
+ ))
260
+ )
261
+ AND (${cursorId}::text IS NULL OR (g."created_at", g."id") < (
262
+ SELECT c."created_at", c."id" FROM "consent_grants" c WHERE c."id" = ${cursorId}::text
263
+ ))
264
+ ORDER BY g."created_at" DESC, g."id" DESC
265
+ LIMIT ${CONSENT_HISTORY_PAGE_SIZE + 1}`;
266
+ const page = rows.slice(0, CONSENT_HISTORY_PAGE_SIZE);
267
+ const last = page[page.length - 1];
268
+ return {
269
+ rows: page,
270
+ nextCursor:
271
+ rows.length > CONSENT_HISTORY_PAGE_SIZE && last ? last.id : null,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * The newest row recording a visitor's choice for one purpose — the cookie
277
+ * writer's dedupe: each CHANGE of choice is logged, a replay adds nothing
278
+ * (D3, SEC-8438-2).
279
+ */
280
+ export async function latestVisitorChoice(
281
+ workspaceId: string,
282
+ visitorId: string,
283
+ purposeId: string,
284
+ db: ConsentReadClient = prisma,
285
+ ): Promise<{ granted: boolean; policyVersion: number | null } | null> {
286
+ const rows = await db.$queryRaw<
287
+ Array<{ granted: boolean; policyVersion: number | null }>
288
+ >`
289
+ SELECT g."granted", g."policy_version" AS "policyVersion"
290
+ FROM "consent_grants" g
291
+ WHERE g."workspace_id" = ${workspaceId}
292
+ AND g."subject_type" = 'visitor'
293
+ AND g."subject_id" = ${visitorId}
294
+ AND g."purpose_id" = ${purposeId}
295
+ ORDER BY g."created_at" DESC, g."id" DESC
296
+ LIMIT 1`;
297
+ return rows[0] ?? null;
298
+ }
299
+
300
+ /**
301
+ * When the caller's own `User` row was created — the sign-up window's only
302
+ * input (SEC-8438-5: `via: "sign_up"` is honored for a user created within
303
+ * the last 24 hours). `null` when no such user exists.
304
+ */
305
+ export async function userCreatedAt(
306
+ userId: string,
307
+ db: Pick<Prisma.TransactionClient, "user"> = prisma,
308
+ ): Promise<Date | null> {
309
+ const user = await db.user.findUnique({
310
+ where: { id: userId },
311
+ select: { createdAt: true },
312
+ });
313
+ return user?.createdAt ?? null;
314
+ }
315
+
316
+ /**
317
+ * The owner's rows for the Working Theory platform purposes (C8): subject
318
+ * `user` / `userId`, newest per purpose, at most 100.
319
+ */
320
+ export async function userPlatformRows(
321
+ workspaceId: string,
322
+ userId: string,
323
+ db: ConsentReadClient = prisma,
324
+ ): Promise<ConsentStateRow[]> {
325
+ return db.$queryRaw<ConsentStateRow[]>`
326
+ SELECT DISTINCT ON (g."purpose_id")
327
+ g."id", g."purpose_id" AS "purposeId", g."granted", g."created_at" AS "at",
328
+ g."source", g."source_ref" AS "sourceRef", g."policy_version" AS "policyVersion"
329
+ FROM "consent_grants" g
330
+ WHERE g."workspace_id" = ${workspaceId}
331
+ AND g."subject_type" = 'user'
332
+ AND g."subject_id" = ${userId}
333
+ ORDER BY g."purpose_id", g."created_at" DESC, g."id" DESC
334
+ LIMIT ${PLATFORM_ROWS_MAX}`;
335
+ }
@@ -8,7 +8,12 @@
8
8
  * '@working-theory/stripe-client' — single chokepoint for AILK-internal ergonomics
9
9
  * (logger context, request-id propagation) without touching every consumer.
10
10
  */
11
- import { getStripeClient } from "@working-theory/stripe-client";
11
+ import {
12
+ admitLiveRestrictedKey,
13
+ assertSandboxKey,
14
+ getStripeClient,
15
+ StripeConfigError,
16
+ } from "@working-theory/stripe-client";
12
17
 
13
18
  export {
14
19
  // PRECONDITION (#6113): do not narrow or bypass this sandbox-only guard for a
@@ -18,6 +23,8 @@ export {
18
23
  // Widened by #6852 (the #6113 follow-up): the same function must bind only
19
24
  // on a server-stamped metadata.ailk_user_id — an unverified Checkout email
20
25
  // never makes a FIRST binding either.
26
+ // Both are on main (#6850, #6862); #8450 is the narrowing this guarded —
27
+ // `assertAdmittedStripeKey` below. This export itself stays sandbox-only.
21
28
  assertSandboxKey,
22
29
  createAdHocPrice,
23
30
  createBillingPortalSession,
@@ -60,6 +67,89 @@ export type {
60
67
  StripeWebhookEvent,
61
68
  } from "@working-theory/stripe-client";
62
69
 
70
+ /**
71
+ * apps/api's live-key admission (#8450 D2 — D-68's apps/api leg, the half
72
+ * #6881 left unbuilt). Composed at runtime so this file's own text never
73
+ * trips the repo-wide live-key lint.
74
+ */
75
+ const LIVE_RESTRICTED_KEY_PREFIX = ["rk", "live", ""].join("_");
76
+
77
+ let liveKeyAdmitted = false;
78
+
79
+ /**
80
+ * `VERCEL_ENV` when set, else `NODE_ENV` — packages/spoke-checkout's own
81
+ * rule, with one fail-closed delta (PR #8470 security review R-1): on Vercel
82
+ * (`VERCEL` set) a missing `VERCEL_ENV` is not production, because
83
+ * `vercel.json` sets `NODE_ENV=production` in every environment, Preview
84
+ * included.
85
+ */
86
+ function isProductionBuild(): boolean {
87
+ if (process.env.VERCEL_ENV !== undefined) {
88
+ return process.env.VERCEL_ENV === "production";
89
+ }
90
+ if (process.env.VERCEL !== undefined) return false;
91
+ return process.env.NODE_ENV === "production";
92
+ }
93
+
94
+ /**
95
+ * Admit this process's Stripe key at server build, or throw
96
+ * `StripeConfigError` (#8450 D2). Called once, by `server.ts`'s `build()`,
97
+ * and nowhere else.
98
+ *
99
+ * Only a restricted live key, beside `STRIPE_MODE=live`, on a production
100
+ * build is admitted: the package's one-way flag is set and `"live"` comes
101
+ * back. `STRIPE_MODE` unset or `test` is today's boot — `assertSandboxKey`,
102
+ * its message unchanged — and returns `"test"`. Every other combination
103
+ * throws. It takes no parameter and reads `process.env` only, so it vouches
104
+ * for the same key `getStripeClient()` later reads. No message names any
105
+ * part of the key.
106
+ */
107
+ export function admitApiStripeKey(): "test" | "live" {
108
+ const mode = process.env.STRIPE_MODE;
109
+ const key = process.env.STRIPE_SECRET_KEY ?? "";
110
+
111
+ if (mode !== undefined && mode !== "test" && mode !== "live") {
112
+ throw new StripeConfigError('STRIPE_MODE must be "test" or "live".');
113
+ }
114
+ if (mode === "live") {
115
+ if (!key.startsWith(LIVE_RESTRICTED_KEY_PREFIX)) {
116
+ throw new StripeConfigError(
117
+ "STRIPE_MODE=live needs a restricted live Stripe key. " +
118
+ "Unrestricted, publishable and test-mode keys are refused.",
119
+ );
120
+ }
121
+ if (!isProductionBuild()) {
122
+ throw new StripeConfigError(
123
+ "A restricted live Stripe key is admitted only with STRIPE_MODE=live on a production build " +
124
+ "(VERCEL_ENV=production, or NODE_ENV=production off Vercel).",
125
+ );
126
+ }
127
+ admitLiveRestrictedKey();
128
+ liveKeyAdmitted = true;
129
+ return "live";
130
+ }
131
+ assertSandboxKey(key);
132
+ return "test";
133
+ }
134
+
135
+ /**
136
+ * The request-time key check for the routes that may run live (#8450 D3):
137
+ * the member portal session and the checkout start and read. It returns
138
+ * before the sandbox-only guard for the restricted live class alone, and
139
+ * only once `admitApiStripeKey()` admitted it; every other key meets
140
+ * `assertSandboxKey`'s allow-list, so an unrestricted or publishable live
141
+ * key is refused whether or not the process admitted.
142
+ */
143
+ export function assertAdmittedStripeKey(key: string): void {
144
+ if (liveKeyAdmitted && key.startsWith(LIVE_RESTRICTED_KEY_PREFIX)) return;
145
+ assertSandboxKey(key);
146
+ }
147
+
148
+ /** The key class this process admitted at boot: `"live"` only after `admitApiStripeKey()` did. */
149
+ export function stripeKeyMode(): "test" | "live" {
150
+ return liveKeyAdmitted ? "live" : "test";
151
+ }
152
+
63
153
  /**
64
154
  * Expire a Checkout Session (#4912 FR7/D7).
65
155
  *
@@ -67,8 +157,9 @@ export type {
67
157
  * session already exists, its id never reached the `FlowCheckout` row, and
68
158
  * nothing will ever join the two — so the session is closed rather than left
69
159
  * live and payable (#4863). Built on the already-re-exported
70
- * `getStripeClient`, whose construction asserts the `sk_test_` allow-list, so
71
- * this call inherits the test-mode gate like every other Stripe call here.
160
+ * `getStripeClient`, whose construction asserts its key allow-list, so this
161
+ * call inherits the same gate as every other Stripe call here (test-mode, or
162
+ * the restricted live key once `admitApiStripeKey()` admitted it, #8450).
72
163
  */
73
164
  export async function expireCheckoutSession(stripeSessionId: string): Promise<void> {
74
165
  await getStripeClient().checkout.sessions.expire(stripeSessionId);
@@ -90,8 +181,9 @@ const PLAN_NAME_MAX = 200;
90
181
  * first item's product name (else the price nickname); and its default
91
182
  * payment method (else the customer's invoice default). At most three reads,
92
183
  * no expand path deeper than three levels (SEC-8171-3). Built on
93
- * `getStripeClient`, so it inherits the `sk_test_` allow-list: a live or
94
- * missing key throws `StripeConfigError`. It logs nothing and returns no id.
184
+ * `getStripeClient`, so it inherits its allow-list: a missing key, or a live
185
+ * key this process did not admit (#8450), throws `StripeConfigError`. It logs
186
+ * nothing and returns no id.
95
187
  */
96
188
  export async function readCustomerBillingSummary(
97
189
  customerId: string,
@@ -5,7 +5,7 @@
5
5
  * docs/specs/7894-the-hub-signs-in-its-spec.md D6; AC-4, AC-5, AC-7).
6
6
  *
7
7
  * A bearer starting `ailk_hst_` is admitted only beside the SAME spoke's key,
8
- * only while its grant is live and its person verified, and only on the 18
8
+ * only while its grant is live and its person verified, and only on the 20
9
9
  * routes of `HUB_SESSION_ROUTES` (#8220 D1, AC-1). Any other route answers 404
10
10
  * with no handler run (the status `lib/account-config.ts`'s `read()` already
11
11
  * loads as "nothing for this caller"), never 403. Each (person, spoke) is
@@ -53,7 +53,7 @@ let spokeA: SeededSpoke;
53
53
  let spokeB: SeededSpoke;
54
54
  let handlerRuns: string[];
55
55
 
56
- /** D1's 18 strings, in the list's order. */
56
+ /** D1's 18 strings plus #8435's two contact routes and #8438's two consents routes (22), in the list's order. */
57
57
  const ADMITTED = [
58
58
  "GET /v1/hub-sign-in/me",
59
59
  "GET /v1/member/pieces",
@@ -72,11 +72,17 @@ const ADMITTED = [
72
72
  "POST /v1/member/requests",
73
73
  "POST /v1/member/messages",
74
74
  "PATCH /v1/member/profile",
75
+ "GET /v1/member/profile/contact",
76
+ "PATCH /v1/member/profile/contact",
75
77
  "POST /v1/member/deletion-request",
78
+ "GET /v1/member/consents",
79
+ "POST /v1/member/consents",
76
80
  ];
77
81
 
78
82
  /** Every route the credential must NOT reach, each a recording stub. */
79
83
  const OFF_LIST = [
84
+ "GET /v1/consent/platform",
85
+ "POST /v1/consent/platform",
80
86
  "GET /v1/member/other",
81
87
  "GET /v1/member/messages/:id",
82
88
  "GET /v1/waitlist-signups/me",
@@ -168,9 +174,9 @@ afterEach(async () => {
168
174
  });
169
175
 
170
176
  describe("HUB_SESSION_ROUTES", () => {
171
- it("is exactly the 18 entries, by method and registered URL — never a prefix (AC-1)", () => {
177
+ it("is exactly the 20 entries, by method and registered URL — never a prefix (AC-1)", () => {
172
178
  expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
173
- expect(HUB_SESSION_ROUTES.length).toBe(18);
179
+ expect(HUB_SESSION_ROUTES.length).toBe(22);
174
180
  for (const route of HUB_SESSION_ROUTES) {
175
181
  expect({
176
182
  route,
@@ -836,10 +836,61 @@ describe("per-visitor forwarding, scope namespace and per-scope env limit (#7394
836
836
  { method: "POST", url: "/v1/leads/callback" },
837
837
  { method: "GET", url: AVAILABILITY },
838
838
  { method: "POST", url: BOOK },
839
+ { method: "POST", url: "/v1/consent/cookie" },
840
+ { method: "POST", url: "/v1/consent/unsubscribe" },
839
841
  ]);
840
842
  });
841
843
  });
842
844
 
845
+ // #8438 D5/D9 — the cookie banner's log and the unsubscribe landing. Each
846
+ // is registered in its OWN scope (`consent`, `unsubscribe`), exactly as
847
+ // server.ts does, and both are on the forwarded-visitor list.
848
+ describe("the consent routes count each keyed visitor separately, in scopes of their own (#8438)", () => {
849
+ const COOKIE = "/v1/consent/cookie";
850
+ const UNSUBSCRIBE = "/v1/consent/unsubscribe";
851
+
852
+ function consentApp(): FastifyInstance {
853
+ const instance = Fastify({ trustProxy: true });
854
+ for (const [scope, url] of [
855
+ ["consent", COOKIE],
856
+ ["unsubscribe", UNSUBSCRIBE],
857
+ ] as const) {
858
+ instance.register(async (scoped) => {
859
+ scoped.register(rateLimitMiddleware, {
860
+ scope,
861
+ perIpPerMinute: 10,
862
+ trustedKeyDigests: digests(),
863
+ });
864
+ scoped.post(url, async () => ({ ok: true }));
865
+ });
866
+ }
867
+ return instance;
868
+ }
869
+
870
+ async function post(url: string, headers: Record<string, string>): Promise<number> {
871
+ if (!app) throw new Error("no app");
872
+ return (await app.inject({ method: "POST", url, headers })).statusCode;
873
+ }
874
+
875
+ it.each([COOKIE, UNSUBSCRIBE])("%s: two visitors behind one site key are counted separately", async (url) => {
876
+ app = consentApp();
877
+ await app.ready();
878
+ for (let i = 0; i < 10; i++) {
879
+ expect(await post(url, keyed("203.0.113.1"))).toBe(200);
880
+ }
881
+ expect(await post(url, keyed("203.0.113.1"))).toBe(429);
882
+ expect(await post(url, keyed("203.0.113.2"))).toBe(200);
883
+ });
884
+
885
+ it("an exhausted consent bucket leaves unsubscribe answering", async () => {
886
+ app = consentApp();
887
+ await app.ready();
888
+ for (let i = 0; i < 10; i++) await post(COOKIE, keyed("203.0.113.1"));
889
+ expect(await post(COOKIE, keyed("203.0.113.1"))).toBe(429);
890
+ expect(await post(UNSUBSCRIBE, keyed("203.0.113.1"))).toBe(200);
891
+ });
892
+ });
893
+
843
894
  describe("AC-5 — visitor IP validation and normalisation", () => {
844
895
  function identityApp(): FastifyInstance {
845
896
  const instance = Fastify({ trustProxy: true });
@@ -34,7 +34,7 @@
34
34
  * spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
35
35
  * unexpired, its client enabled and its person's email verified, and only
36
36
  * on `HUB_SESSION_ROUTES` — an exact list of METHOD + registered URL
37
- * pairs — the credential's own `me` and the member routes, 18 in all
37
+ * pairs — the credential's own `me` and the member routes, 20 in all
38
38
  * (#8220); any other route is 404 (never 403, so the account area's
39
39
  * `read()` loads "nothing for this caller"). It acts as its person
40
40
  * (`request.userId`) and carries `request.hubSession` — never
@@ -153,9 +153,11 @@ export type HubSession = {
153
153
  * The only routes a hub-issued credential reaches (#7894 D6, #8220 D1): an
154
154
  * EXACT list of method + registered URL, never a prefix and never computed,
155
155
  * so a route added later joins only by a reviewed edit of this list
156
- * (SEC-7894-3). Three groups, in this order: the credential's own `me` and
156
+ * (SEC-7894-3). Four groups, in this order: the credential's own `me` and
157
157
  * #7877's three member reads; #8169's eight member reads; #8170's six member
158
- * writes (roadmap §7.2 row order). The member profile write IS admitted — it
158
+ * writes (roadmap §7.2 row order); #8438's member consents read and write.
159
+ * `GET /v1/consent/platform` is not admitted — it is the Admin owner's own
160
+ * consent to Working Theory, on the hub's cookie session. The member profile write IS admitted — it
159
161
  * is the person's own account, which roadmap D9 saves through it. Not
160
162
  * admitted, deliberately: `GET /v1/waitlist-signups/me`,
161
163
  * `GET /v1/flow-checkouts/me`, `GET /v1/subscription/me` and
@@ -181,7 +183,13 @@ export const HUB_SESSION_ROUTES: readonly string[] = Object.freeze([
181
183
  "POST /v1/member/requests",
182
184
  "POST /v1/member/messages",
183
185
  "PATCH /v1/member/profile",
186
+ "GET /v1/member/profile/contact",
187
+ "PATCH /v1/member/profile/contact",
184
188
  "POST /v1/member/deletion-request",
189
+ // #8438 D6 — the member's own consents. `GET /v1/consent/platform` is NOT
190
+ // admitted: those are the Admin owner's, on the hub's own cookie session.
191
+ "GET /v1/member/consents",
192
+ "POST /v1/member/consents",
185
193
  ]);
186
194
 
187
195
  /**
@@ -170,6 +170,11 @@ export const FORWARDED_VISITOR_ROUTES: readonly Readonly<{
170
170
  { method: "POST", url: "/v1/leads/callback" },
171
171
  { method: "GET", url: "/v1/schedule/availability" },
172
172
  { method: "POST", url: "/v1/schedule/book" },
173
+ // #8438 D5/D9 — the cookie banner's log and the unsubscribe landing: a
174
+ // site's server forwards each visitor's IP, so one spoke's visitors are
175
+ // counted one by one rather than in one bucket keyed on its egress IP.
176
+ { method: "POST", url: "/v1/consent/cookie" },
177
+ { method: "POST", url: "/v1/consent/unsubscribe" },
173
178
  ];
174
179
 
175
180
  const VISITOR_IP_HEADER = "x-ailk-visitor-ip";