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
@@ -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,20 @@ 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",
80
+ // #8515 D13 — the client's onboarding read and write.
81
+ "GET /v1/member/onboarding",
82
+ "POST /v1/member/onboarding",
76
83
  ];
77
84
 
78
85
  /** Every route the credential must NOT reach, each a recording stub. */
79
86
  const OFF_LIST = [
87
+ "GET /v1/consent/platform",
88
+ "POST /v1/consent/platform",
80
89
  "GET /v1/member/other",
81
90
  "GET /v1/member/messages/:id",
82
91
  "GET /v1/waitlist-signups/me",
@@ -168,9 +177,9 @@ afterEach(async () => {
168
177
  });
169
178
 
170
179
  describe("HUB_SESSION_ROUTES", () => {
171
- it("is exactly the 18 entries, by method and registered URL — never a prefix (AC-1)", () => {
180
+ it("is exactly the 24 entries, by method and registered URL — never a prefix (AC-1, #8515 AC-6)", () => {
172
181
  expect([...HUB_SESSION_ROUTES]).toEqual(ADMITTED);
173
- expect(HUB_SESSION_ROUTES.length).toBe(18);
182
+ expect(HUB_SESSION_ROUTES.length).toBe(24);
174
183
  for (const route of HUB_SESSION_ROUTES) {
175
184
  expect({
176
185
  route,
@@ -580,6 +580,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
580
580
  organizationId: DEFAULT_ORG_ID,
581
581
  workspaceId: DEFAULT_WORKSPACE_ID,
582
582
  role: "member",
583
+ agent: true,
583
584
  });
584
585
  await app.close();
585
586
  });
@@ -599,6 +600,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
599
600
  organizationId: DEFAULT_ORG_ID,
600
601
  workspaceId: DEFAULT_WORKSPACE_ID,
601
602
  role: "member",
603
+ agent: true,
602
604
  grants: ["content-edit", "magnets-manage"],
603
605
  });
604
606
  await app.close();
@@ -0,0 +1,220 @@
1
+ /**
2
+ * @file rate-limit-cross-isolate.db.test.ts
3
+ *
4
+ * LIVE-DATABASE proof of the shared store (#5040 D8: AC-1, AC-7, AC-8). Runs
5
+ * only when `AILK_TEST_LIVE_DATABASE === "1"` — `apps/api/src/__tests__/setup.ts`
6
+ * always sets a placeholder `DATABASE_URL`, so the usual
7
+ * `DATABASE_URL ? describe : describe.skip` gate can't distinguish a real
8
+ * database from the placeholder; `.github/workflows/api-postgres-compat.yml`'s
9
+ * full-suite step sets this variable beside its real `DATABASE_URL`.
10
+ *
11
+ * Uses the `pg` driver directly (raw SQL), the same reason
12
+ * `database/__tests__/prefixed-ids.db.test.ts` does: the generated Prisma
13
+ * client is ESM-only and cannot load under this package's CJS ts-jest
14
+ * transform. AC-1 drives the store's SQL through a `pg.Client` shim that
15
+ * turns a tagged template's strings and values into `$1…$n` text, not
16
+ * through the Prisma binding production uses — that binding is exercised by
17
+ * `rate-limit-store.test.ts`'s mocked-SQL-client unit tests.
18
+ */
19
+ import { createHash, randomUUID } from "node:crypto";
20
+
21
+ import Fastify, { type FastifyInstance } from "fastify";
22
+ import { Client } from "pg";
23
+
24
+ import {
25
+ createDatabaseRateLimitStore,
26
+ type RateLimitSqlClient,
27
+ } from "../../lib/rate-limit-store.js";
28
+
29
+ const describeDb =
30
+ process.env.AILK_TEST_LIVE_DATABASE === "1" ? describe : describe.skip;
31
+
32
+ function sha256Hex(value: string): string {
33
+ return createHash("sha256").update(value).digest("hex");
34
+ }
35
+
36
+ /** Turns a tagged template's strings + values into `$1…$n` text for `pg`. */
37
+ function sqlClient(pgClient: Client): RateLimitSqlClient {
38
+ function toParamText(
39
+ strings: TemplateStringsArray,
40
+ values: readonly unknown[],
41
+ ): string {
42
+ let text = strings[0] ?? "";
43
+ for (let i = 0; i < values.length; i++) {
44
+ text += `$${i + 1}${strings[i + 1] ?? ""}`;
45
+ }
46
+ return text;
47
+ }
48
+
49
+ async function queryRaw(
50
+ strings: TemplateStringsArray,
51
+ ...values: unknown[]
52
+ ): Promise<unknown[]> {
53
+ const res = await pgClient.query(toParamText(strings, values), values);
54
+ return res.rows;
55
+ }
56
+
57
+ async function executeRaw(
58
+ strings: TemplateStringsArray,
59
+ ...values: unknown[]
60
+ ): Promise<number> {
61
+ const res = await pgClient.query(toParamText(strings, values), values);
62
+ return res.rowCount ?? 0;
63
+ }
64
+
65
+ return {
66
+ $queryRaw: queryRaw as RateLimitSqlClient["$queryRaw"],
67
+ $executeRaw: executeRaw as RateLimitSqlClient["$executeRaw"],
68
+ };
69
+ }
70
+
71
+ describeDb("rate limiter — the shared database store (live, #5040)", () => {
72
+ let db: Client;
73
+
74
+ beforeAll(async () => {
75
+ db = new Client({ connectionString: process.env.DATABASE_URL });
76
+ await db.connect();
77
+ });
78
+
79
+ afterAll(async () => {
80
+ await db.end();
81
+ });
82
+
83
+ it("AC-1: 20 concurrent requests spread 10/10 over two isolated module instances, one identity, perIpPerMinute 5 — exactly 5×200, 15×429, stored count 20", async () => {
84
+ const identity = `rl-demo-${randomUUID()}`;
85
+ const clientA = new Client({ connectionString: process.env.DATABASE_URL });
86
+ const clientB = new Client({ connectionString: process.env.DATABASE_URL });
87
+ await clientA.connect();
88
+ await clientB.connect();
89
+
90
+ let appA: FastifyInstance | undefined;
91
+ let appB: FastifyInstance | undefined;
92
+
93
+ // Each isolateModules block gives a fresh require cache: requiring
94
+ // rate-limit-store AND rate-limit inside it yields module instances
95
+ // distinct from every other test file's top-level import, with their
96
+ // own process-default-store memoization — exactly the D8 pattern PR
97
+ // #5544 already used for the memory-store proof.
98
+ jest.isolateModules(() => {
99
+ const storeMod: typeof import("../../lib/rate-limit-store") = require("../../lib/rate-limit-store");
100
+ const rateLimitMod: typeof import("../rate-limit") = require("../rate-limit");
101
+ const app = Fastify({ trustProxy: true });
102
+ app.register(rateLimitMod.rateLimitMiddleware, {
103
+ perIpPerMinute: 5,
104
+ store: storeMod.createDatabaseRateLimitStore(sqlClient(clientA)),
105
+ });
106
+ app.get("/test", async () => ({ ok: true }));
107
+ appA = app;
108
+ });
109
+
110
+ jest.isolateModules(() => {
111
+ const storeMod: typeof import("../../lib/rate-limit-store") = require("../../lib/rate-limit-store");
112
+ const rateLimitMod: typeof import("../rate-limit") = require("../rate-limit");
113
+ const app = Fastify({ trustProxy: true });
114
+ app.register(rateLimitMod.rateLimitMiddleware, {
115
+ perIpPerMinute: 5,
116
+ store: storeMod.createDatabaseRateLimitStore(sqlClient(clientB)),
117
+ });
118
+ app.get("/test", async () => ({ ok: true }));
119
+ appB = app;
120
+ });
121
+
122
+ if (!appA || !appB) {
123
+ throw new Error("isolateModules did not synchronously assign both apps");
124
+ }
125
+ await appA.ready();
126
+ await appB.ready();
127
+
128
+ try {
129
+ const inject = (app: FastifyInstance) =>
130
+ app.inject({
131
+ method: "GET",
132
+ url: "/test",
133
+ headers: { "x-forwarded-for": identity },
134
+ });
135
+ const results = await Promise.all([
136
+ ...Array.from({ length: 10 }, () => inject(appA!)),
137
+ ...Array.from({ length: 10 }, () => inject(appB!)),
138
+ ]);
139
+ const statuses = results.map((r) => r.statusCode);
140
+ expect(statuses.filter((s) => s === 200)).toHaveLength(5);
141
+ expect(statuses.filter((s) => s === 429)).toHaveLength(15);
142
+
143
+ const hash = sha256Hex(identity);
144
+ const row = await db.query(
145
+ `SELECT "count" FROM "rate_limit_counters" WHERE "key_hash" = $1`,
146
+ [hash],
147
+ );
148
+ expect(row.rows[0]?.count).toBe(20);
149
+ } finally {
150
+ await appA.close();
151
+ await appB.close();
152
+ await clientA.end();
153
+ await clientB.end();
154
+ }
155
+ });
156
+
157
+ it("AC-7: sweep deletes an expired row only on the interval trigger, never on 'limited'", async () => {
158
+ const store = createDatabaseRateLimitStore(sqlClient(db));
159
+ const now = Date.now();
160
+ const staleHash = sha256Hex(`rl-stale-${randomUUID()}`);
161
+ const openHash = sha256Hex(`rl-open-${randomUUID()}`);
162
+
163
+ await db.query(
164
+ `INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count") VALUES ($1, $2, $3, 1)`,
165
+ [staleHash, now - 120_000, 60_000],
166
+ );
167
+ await db.query(
168
+ `INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count") VALUES ($1, $2, $3, 1)`,
169
+ [openHash, now, 60_000],
170
+ );
171
+
172
+ await store.sweep(now, 60_000, "limited");
173
+ let rows = await db.query(
174
+ `SELECT "key_hash" FROM "rate_limit_counters" WHERE "key_hash" IN ($1, $2)`,
175
+ [staleHash, openHash],
176
+ );
177
+ expect(rows.rows).toHaveLength(2);
178
+
179
+ await store.sweep(now, 60_000, "interval");
180
+ rows = await db.query(
181
+ `SELECT "key_hash" FROM "rate_limit_counters" WHERE "key_hash" IN ($1, $2)`,
182
+ [staleHash, openHash],
183
+ );
184
+ expect(rows.rows.map((r: { key_hash: string }) => r.key_hash)).toEqual([
185
+ openHash,
186
+ ]);
187
+ });
188
+
189
+ it("AC-8: key_hash is always a sha256 digest, never the raw identity, and both format CHECKs hold", async () => {
190
+ const store = createDatabaseRateLimitStore(sqlClient(db));
191
+ const identity = `203.0.113.${1 + Math.floor(Math.random() * 254)}`;
192
+ const now = Date.now();
193
+ await store.hit(identity, now, 60_000);
194
+
195
+ const allRows = await db.query(`SELECT "key_hash" FROM "rate_limit_counters"`);
196
+ for (const row of allRows.rows as Array<{ key_hash: string }>) {
197
+ expect(row.key_hash).not.toContain("203.0.113");
198
+ }
199
+ const hash = sha256Hex(identity);
200
+ const matched = await db.query(
201
+ `SELECT 1 FROM "rate_limit_counters" WHERE "key_hash" = $1`,
202
+ [hash],
203
+ );
204
+ expect(matched.rows).toHaveLength(1);
205
+
206
+ await expect(
207
+ db.query(
208
+ `INSERT INTO "rate_limit_counters" ("key_hash", "window_start_ms", "window_ms", "count") VALUES ('not-hex', $1, 60000, 1)`,
209
+ [now],
210
+ ),
211
+ ).rejects.toThrow(/rate_limit_counters_key_hash_hex/);
212
+
213
+ await expect(
214
+ db.query(
215
+ `INSERT INTO "rate_limit_counters" ("id", "key_hash", "window_start_ms", "window_ms", "count") VALUES ('bad', $1, $2, 60000, 1)`,
216
+ [sha256Hex(`rl-bad-id-${randomUUID()}`), now],
217
+ ),
218
+ ).rejects.toThrow(/rate_limit_counters_id_format/);
219
+ });
220
+ });