create-ailk 0.23.0 → 0.24.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 (96) hide show
  1. package/README.md +27 -14
  2. package/dist/add-module.js +6 -1
  3. package/dist/add-route.js +7 -1
  4. package/dist/derive-architecture.js +17 -0
  5. package/dist/lib/scaffold-render-sweep.d.ts +33 -0
  6. package/dist/lib/scaffold-render-sweep.js +45 -0
  7. package/dist/module-architecture.js +17 -0
  8. package/dist/sync-routes.d.ts +30 -4
  9. package/dist/sync-routes.js +271 -18
  10. package/package.json +3 -3
  11. package/templates/.env.example +4 -0
  12. package/templates/CLAUDE.md +4 -2
  13. package/templates/apps/api/.env.example +7 -0
  14. package/templates/apps/api/CLAUDE.md +25 -11
  15. package/templates/apps/api/src/lib/__mocks__/prisma.ts +4 -1
  16. package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
  17. package/templates/apps/api/src/lib/site.ts +4 -1
  18. package/templates/apps/api/src/lib/stripe.ts +3 -0
  19. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +127 -0
  20. package/templates/apps/api/src/openapi/spec.ts +333 -0
  21. package/templates/apps/api/src/routes/billing/plan-checkout.ts +18 -11
  22. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +126 -0
  23. package/templates/apps/api/src/routes/content/get-page.ts +34 -119
  24. package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
  25. package/templates/apps/api/src/routes/content/search.ts +16 -45
  26. package/templates/apps/api/src/routes/leads/index.ts +364 -298
  27. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
  28. package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
  29. package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
  30. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -0
  31. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +1 -0
  32. package/templates/apps/api/src/routes/webhooks/stripe.ts +2 -0
  33. package/templates/apps/api/src/server.ts +25 -0
  34. package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
  35. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +10 -0
  36. package/templates/apps/api/src/services/__tests__/person.test.ts +28 -0
  37. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +6 -0
  38. package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
  39. package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
  40. package/templates/apps/api/src/services/abandon-email.ts +28 -5
  41. package/templates/apps/api/src/services/deliverable-fulfillment.ts +2 -7
  42. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +53 -0
  43. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +107 -0
  44. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +4 -0
  45. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +4 -0
  46. package/templates/apps/api/src/services/offerings/catalog.ts +10 -0
  47. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +23 -5
  48. package/templates/apps/api/src/services/offerings/connection.ts +83 -3
  49. package/templates/apps/api/src/services/offerings/index.ts +6 -6
  50. package/templates/apps/api/src/services/person.ts +23 -0
  51. package/templates/apps/api/src/services/project-listings.ts +43 -31
  52. package/templates/apps/mcp/CLAUDE.md +11 -5
  53. package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
  54. package/templates/apps/mcp/__tests__/http-transport.test.ts +91 -10
  55. package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
  56. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
  57. package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
  58. package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
  59. package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
  60. package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
  61. package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
  62. package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
  63. package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
  64. package/templates/apps/mcp/__tests__/submit_lead.test.ts +82 -167
  65. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
  66. package/templates/apps/mcp/package.json +1 -0
  67. package/templates/apps/mcp/src/abuse/index.ts +18 -11
  68. package/templates/apps/mcp/src/http-transport.ts +47 -9
  69. package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
  70. package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
  71. package/templates/apps/mcp/src/lib/site-config.ts +53 -0
  72. package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
  73. package/templates/apps/mcp/src/tools/index.ts +22 -21
  74. package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
  75. package/templates/apps/mcp/src/tools/read_content.ts +46 -40
  76. package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
  77. package/templates/apps/mcp/src/tools/search_content.ts +20 -24
  78. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
  79. package/templates/apps/web/CLAUDE.md +12 -3
  80. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +1 -1
  81. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +14 -4
  82. package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
  83. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +21 -4
  84. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +7 -0
  85. package/templates/apps/web/lib/magnet-routes.ts +3 -1
  86. package/templates/apps/web/lib/newsletter.actions.ts +9 -6
  87. package/templates/database/CHANGELOG.md +20 -0
  88. package/templates/database/__tests__/seed.test.ts +33 -1
  89. package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
  90. package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +28 -0
  91. package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
  92. package/templates/database/package.json +1 -1
  93. package/templates/database/seed.ts +27 -0
  94. package/templates/tools/lint-architecture.ts +205 -1
  95. package/versions.json +34 -34
  96. package/templates/apps/api/src/routes/content/slug.ts +0 -9
@@ -35,7 +35,7 @@ jest.mock("@working-theory/license", () => ({
35
35
  jest.mock("../../lib/prisma.js", () => ({
36
36
  prisma: {
37
37
  projectListing: { findMany: jest.fn() },
38
- waitlistScore: { count: jest.fn() },
38
+ waitlistScore: { groupBy: jest.fn() },
39
39
  },
40
40
  }));
41
41
 
@@ -102,25 +102,25 @@ function listingRow(id: string, experimentKey: string | null): Record<string, un
102
102
 
103
103
  beforeEach(() => {
104
104
  jest.clearAllMocks();
105
- (prisma.waitlistScore.count as jest.Mock).mockResolvedValue(3);
105
+ (prisma.waitlistScore.groupBy as jest.Mock).mockResolvedValue([]);
106
106
  });
107
107
 
108
108
  describe("attachWaitlistCounts — the fenced count filters on workspaceId beside siteId (#6258 AC-6)", () => {
109
- it("prisma.waitlistScore.count's where carries workspaceId === the resolved scope id, by value", async () => {
109
+ it("prisma.waitlistScore.groupBy's where carries workspaceId === the resolved scope id, by value", async () => {
110
110
  (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
111
111
  listingRow("plst_a", EXPERIMENT_A),
112
112
  ]);
113
113
 
114
114
  await listProjectListings(SITE);
115
115
 
116
- expect((prisma.waitlistScore.count as jest.Mock).mock.calls[0]![0]!.where).toEqual({
116
+ expect((prisma.waitlistScore.groupBy as jest.Mock).mock.calls[0]![0]!.where).toEqual({
117
117
  siteId: SITE,
118
118
  workspaceId: SITE,
119
- experimentKey: EXPERIMENT_A,
119
+ experimentKey: { in: [EXPERIMENT_A] },
120
120
  });
121
121
  });
122
122
 
123
- it("every DISTINCT experimentKey gets the same two scope terms — one count per key, no more", async () => {
123
+ it("every DISTINCT experimentKey sits under the same two scope terms — one grouped read, no more (#9243)", async () => {
124
124
  (prisma.projectListing.findMany as jest.Mock).mockResolvedValue([
125
125
  listingRow("plst_a", EXPERIMENT_A),
126
126
  listingRow("plst_b", EXPERIMENT_B),
@@ -129,17 +129,14 @@ describe("attachWaitlistCounts — the fenced count filters on workspaceId besid
129
129
 
130
130
  await listProjectListings(SITE);
131
131
 
132
- expect(prisma.waitlistScore.count).toHaveBeenCalledTimes(2);
133
- const wheres = (prisma.waitlistScore.count as jest.Mock).mock.calls.map(
134
- (call) => call[0]!.where as Record<string, unknown>,
135
- );
136
- for (const where of wheres) {
137
- expect(where.siteId).toBe(SITE);
138
- expect(where.workspaceId).toBe(SITE);
139
- }
140
- expect(wheres.map((w) => w.experimentKey).sort()).toEqual(
141
- [EXPERIMENT_A, EXPERIMENT_B].sort(),
142
- );
132
+ expect(prisma.waitlistScore.groupBy).toHaveBeenCalledTimes(1);
133
+ const where = (prisma.waitlistScore.groupBy as jest.Mock).mock.calls[0]![0]!.where as Record<
134
+ string,
135
+ unknown
136
+ >;
137
+ expect(where.siteId).toBe(SITE);
138
+ expect(where.workspaceId).toBe(SITE);
139
+ expect(where.experimentKey).toEqual({ in: [EXPERIMENT_A, EXPERIMENT_B] });
143
140
  });
144
141
 
145
142
  it("no listing carries an experimentKey → no fenced read is issued at all", async () => {
@@ -149,6 +146,6 @@ describe("attachWaitlistCounts — the fenced count filters on workspaceId besid
149
146
 
150
147
  await listProjectListings(SITE);
151
148
 
152
- expect(prisma.waitlistScore.count).not.toHaveBeenCalled();
149
+ expect(prisma.waitlistScore.groupBy).not.toHaveBeenCalled();
153
150
  });
154
151
  });
@@ -134,6 +134,16 @@ function buildResumeLink(
134
134
  return `${resumeUrl}?fs=${sessionId}`;
135
135
  }
136
136
 
137
+ // ─── Candidate paging (#9249) ───────────────────────────────────────────────────
138
+
139
+ /**
140
+ * How many candidates one run reads per flow definition. A flow with more
141
+ * past-delay sessions than this processes one page and leaves the rest —
142
+ * still `abandonEmailStatus: null` — for the next run. Same page as
143
+ * `STANDARD_SEED_PAGE` (lib/tenant-db.ts), this app's other background page.
144
+ */
145
+ export const ABANDON_CANDIDATE_PAGE_SIZE = 500;
146
+
137
147
  // ─── The sweep ──────────────────────────────────────────────────────────────────
138
148
 
139
149
  /**
@@ -197,19 +207,21 @@ async function sweepAllDefinitions(
197
207
  email: true,
198
208
  locale: true,
199
209
  },
210
+ // #9249 — oldest-updated first, so the page is deterministic (the same
211
+ // reason flow-aggregate.ts orders its capped read).
212
+ orderBy: { updatedAt: "asc" },
213
+ take: ABANDON_CANDIDATE_PAGE_SIZE,
200
214
  })) as AbandonCandidateRow[];
201
215
 
202
216
  summary.scanned += candidates.length;
203
217
 
218
+ const darkIds: string[] = [];
219
+
204
220
  for (const session of candidates) {
205
221
  summary.selected += 1;
206
222
 
207
223
  if (policy.mode === "dark") {
208
- await prisma.flowSession.update({
209
- where: { id: session.id },
210
- data: { abandonEmailStatus: "dark", abandonEmailAt: now },
211
- });
212
- summary.dark += 1;
224
+ darkIds.push(session.id);
213
225
  logOutcome(session, policy.mode, "dark");
214
226
  continue;
215
227
  }
@@ -265,6 +277,17 @@ async function sweepAllDefinitions(
265
277
  logOutcome(session, policy.mode, "failed");
266
278
  }
267
279
  }
280
+
281
+ // #9249 — one stamp for the whole dark-mode page, not one per row. The
282
+ // `abandonEmailStatus: null` guard is the candidate read's own term, so a
283
+ // row an overlapping run stamped meanwhile keeps what that run wrote.
284
+ if (darkIds.length > 0) {
285
+ await prisma.flowSession.updateMany({
286
+ where: { id: { in: darkIds }, abandonEmailStatus: null },
287
+ data: { abandonEmailStatus: "dark", abandonEmailAt: now },
288
+ });
289
+ summary.dark += darkIds.length;
290
+ }
268
291
  }
269
292
  }
270
293
 
@@ -84,12 +84,6 @@ export type FulfillDeliverableInput = {
84
84
  locale: string;
85
85
  };
86
86
 
87
- type DeliverableAssetRow = {
88
- id: string;
89
- siteId: string;
90
- key: string;
91
- };
92
-
93
87
  // Derived from the schema type (not hand-rolled) so it never drifts from
94
88
  // `deliverableAssetMapEntrySchema`'s actual Zod-inferred optional shape.
95
89
  type DeliverableEntry = NonNullable<
@@ -479,7 +473,8 @@ export async function fulfillDeliverable(
479
473
  // one row; `workspaceId` is what makes the tenant the QUERY's statement.
480
474
  const asset = (await prisma.deliverableAsset.findUnique({
481
475
  where: { siteId_key: { siteId, key: entry.assetKey }, workspaceId: siteId },
482
- })) as DeliverableAssetRow | null;
476
+ select: { id: true },
477
+ })) as { id: string } | null;
483
478
 
484
479
  if (!asset) {
485
480
  logOutcome(logCtx, "asset_missing");
@@ -10,6 +10,7 @@ import { join } from "node:path";
10
10
  import { offeringsSnapshotProductSchema } from "@working-theory/validation";
11
11
 
12
12
  import {
13
+ isOfferingsPlanProduct,
13
14
  isOfferingsProduct,
14
15
  normalizeCatalog,
15
16
  type CatalogPrice,
@@ -286,6 +287,58 @@ describe("normalizeCatalog (#8876 D5, AC-5)", () => {
286
287
  });
287
288
  });
288
289
 
290
+ describe("the hub's own plans (#9338 AC-3)", () => {
291
+ const pro = product("prod_pro", { name: "AI Launch Kit Pro", metadata: { ailk_group: "plan", ailk_order: "10" } });
292
+ const proPlus = product("prod_pro_plus", {
293
+ name: "AI Launch Kit Pro Plus",
294
+ metadata: { ailk_group: "plan", ailk_order: "20" },
295
+ });
296
+ const yearly = { recurring: { interval: "year", interval_count: 1, usage_type: "licensed" } };
297
+
298
+ it("lists the re-keyed hub catalog as two plans with nothing held", () => {
299
+ const { offerings, held } = normalizeCatalog({
300
+ products: [pro, proPlus],
301
+ prices: [
302
+ price("price_pro_m", "prod_pro", { lookup_key: "ailk-pro_monthly" }),
303
+ price("price_pro_y", "prod_pro", { lookup_key: "ailk-pro_yearly", ...yearly }),
304
+ price("price_pp_m", "prod_pro_plus", { lookup_key: "ailk-pro-plus_monthly" }),
305
+ ],
306
+ });
307
+ expect(held).toEqual([]);
308
+ expect(offerings.map((o) => [o.productId, o.ailkGroup, o.prices.map((p) => p.lookupKey)])).toEqual([
309
+ ["prod_pro", "plan", ["ailk-pro_monthly", "ailk-pro_yearly"]],
310
+ ["prod_pro_plus", "plan", ["ailk-pro-plus_monthly"]],
311
+ ]);
312
+ });
313
+
314
+ it("holds today's Pro Plus (no group, key ailk-pro-plus) group_invalid", () => {
315
+ const today = product("prod_pro_plus", { name: "AI Launch Kit Pro Plus", metadata: {} });
316
+ expect(one(today, [price("price_pp", "prod_pro_plus", { lookup_key: "ailk-pro-plus", ...yearly })]).held).toEqual([
317
+ { productId: "prod_pro_plus", name: "AI Launch Kit Pro Plus", reason: "group_invalid" },
318
+ ]);
319
+ });
320
+
321
+ it("holds Pro Plus marked but still keyed ailk-pro-plus needs_lookup_key", () => {
322
+ expect(one(proPlus, [price("price_pp", "prod_pro_plus", { lookup_key: "ailk-pro-plus", ...yearly })]).held).toEqual([
323
+ { productId: "prod_pro_plus", name: "AI Launch Kit Pro Plus", reason: "needs_lookup_key" },
324
+ ]);
325
+ });
326
+
327
+ it("holds a yearly Price keyed _monthly needs_lookup_key", () => {
328
+ expect(
329
+ one(proPlus, [price("price_pp", "prod_pro_plus", { lookup_key: "ailk-pro-plus_monthly", ...yearly })]).held,
330
+ ).toEqual([{ productId: "prod_pro_plus", name: "AI Launch Kit Pro Plus", reason: "needs_lookup_key" }]);
331
+ });
332
+
333
+ it("isOfferingsPlanProduct is true for ailk_group plan only", () => {
334
+ expect(isOfferingsPlanProduct(product("prod_a", { metadata: { ailk_group: "plan" } }))).toBe(true);
335
+ expect(isOfferingsPlanProduct(product("prod_a", { metadata: { ailk_group: "addon" } }))).toBe(false);
336
+ expect(isOfferingsPlanProduct(product("prod_a", { metadata: { ailk_group: "Plan" } }))).toBe(false);
337
+ expect(isOfferingsPlanProduct(product("prod_a", { metadata: {} }))).toBe(false);
338
+ expect(isOfferingsPlanProduct(product("prod_a", { metadata: null }))).toBe(false);
339
+ });
340
+ });
341
+
289
342
  it("splits a lookup key only through parseLookupKey (AC-9)", () => {
290
343
  const source = readFileSync(join(__dirname, "..", "catalog.ts"), "utf8");
291
344
  expect(source).toContain("parseLookupKey(");
@@ -48,6 +48,9 @@ jest.mock("node:crypto", () => {
48
48
  });
49
49
 
50
50
  const mockStripeClientForConnection = jest.fn();
51
+ // `getStripeClient()` caches its client for the process, so a case that needs a
52
+ // refusal swaps it (#9285); every other case calls through to the real one.
53
+ const mockGetStripeClient = jest.fn();
51
54
  // eslint-disable-next-line no-restricted-syntax -- lib/stripe.ts is the Stripe HTTP boundary; the real wrapper runs unless a case swaps in a fake client
52
55
  jest.mock("../../../lib/stripe.js", () => {
53
56
  const actual = jest.requireActual<typeof import("../../../lib/stripe.js")>("../../../lib/stripe.js");
@@ -55,6 +58,7 @@ jest.mock("../../../lib/stripe.js", () => {
55
58
  ...actual,
56
59
  stripeClientForConnection: (...args: Parameters<typeof actual.stripeClientForConnection>) =>
57
60
  mockStripeClientForConnection(...args),
61
+ getStripeClient: () => mockGetStripeClient(),
58
62
  };
59
63
  });
60
64
 
@@ -67,8 +71,11 @@ import {
67
71
  encryptStripeConnectionValue,
68
72
  findStripeConnectionByWebhookToken,
69
73
  formatRekeyReport,
74
+ getEnvironmentStripeSource,
75
+ getOfferingsConnectionState,
70
76
  getStripeConnectionSummary,
71
77
  goLiveStripeConnection,
78
+ organizationOwnsEnvironmentStripeKey,
72
79
  PROBE_TIMEOUT_MS,
73
80
  probeStripeKey,
74
81
  putStripeConnectionKey,
@@ -866,3 +873,103 @@ describe("stripeClientForOrganization (#8907 D12, AC-13)", () => {
866
873
  expect(lib).toContain("productionBuild: isProductionBuild()");
867
874
  });
868
875
  });
876
+
877
+ // ─── #9285 AC-8, AC-9 — the environment key's owner ─────────────────────────
878
+
879
+ describe("the environment Stripe key and the Organization that owns it (#9285 AC-8, AC-9)", () => {
880
+ const saved = {
881
+ key: process.env["STRIPE_SECRET_KEY"],
882
+ modules: process.env["AILK_ENABLED_MODULES"],
883
+ owner: process.env["STRIPE_ENV_ORGANIZATION_ID"],
884
+ };
885
+ const restore = (name: string, value: string | undefined) => {
886
+ if (value === undefined) delete process.env[name];
887
+ else process.env[name] = value;
888
+ };
889
+
890
+ beforeEach(() => {
891
+ db.__db.stripeConnection = [];
892
+ mockGetStripeClient.mockReset().mockImplementation(() => actualStripe.getStripeClient());
893
+ process.env["STRIPE_SECRET_KEY"] = testKey();
894
+ process.env["AILK_ENABLED_MODULES"] = "multi-tenant";
895
+ delete process.env["STRIPE_ENV_ORGANIZATION_ID"];
896
+ });
897
+ afterAll(() => {
898
+ restore("STRIPE_SECRET_KEY", saved.key);
899
+ restore("AILK_ENABLED_MODULES", saved.modules);
900
+ restore("STRIPE_ENV_ORGANIZATION_ID", saved.owner);
901
+ });
902
+
903
+ it("with the multi-tenant module, only the Organization STRIPE_ENV_ORGANIZATION_ID names owns it", () => {
904
+ // Unset: nobody owns it (fail closed).
905
+ expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(false);
906
+ expect(organizationOwnsEnvironmentStripeKey(ORG_B)).toBe(false);
907
+ // Blank or whitespace: nobody.
908
+ for (const blank of ["", " "]) {
909
+ process.env["STRIPE_ENV_ORGANIZATION_ID"] = blank;
910
+ expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(false);
911
+ }
912
+ // Naming no Organization: nobody.
913
+ process.env["STRIPE_ENV_ORGANIZATION_ID"] = "org_unknown";
914
+ expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(false);
915
+ // Naming one: that one alone.
916
+ process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
917
+ expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(true);
918
+ expect(organizationOwnsEnvironmentStripeKey(ORG_B)).toBe(false);
919
+ });
920
+
921
+ it("without the multi-tenant module, the single Organization owns it and the variable is not read", () => {
922
+ process.env["AILK_ENABLED_MODULES"] = "";
923
+ process.env["STRIPE_ENV_ORGANIZATION_ID"] = "org_unknown";
924
+ expect(organizationOwnsEnvironmentStripeKey(ORG_A)).toBe(true);
925
+ delete process.env["AILK_ENABLED_MODULES"];
926
+ expect(organizationOwnsEnvironmentStripeKey(ORG_B)).toBe(true);
927
+ });
928
+
929
+ it("reports the source and the Offerings state to the owner alone, and never a key class it cannot sell through", async () => {
930
+ process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
931
+ expect(getEnvironmentStripeSource(ORG_A)).toEqual({ source: "environment", mode: "test" });
932
+ expect(await getOfferingsConnectionState(ORG_A)).toBe("environment");
933
+ expect(getEnvironmentStripeSource(ORG_B)).toBeNull();
934
+ expect(await getOfferingsConnectionState(ORG_B)).toBeNull();
935
+ // No usable key class: no source, even for the owner.
936
+ process.env["STRIPE_SECRET_KEY"] = ["pk", "test", "a".repeat(24)].join("_");
937
+ expect(getEnvironmentStripeSource(ORG_A)).toBeNull();
938
+ expect(await getOfferingsConnectionState(ORG_A)).toBeNull();
939
+ });
940
+
941
+ it("lets a stored connection win over the environment state", async () => {
942
+ process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
943
+ const now = new Date();
944
+ rows().push({
945
+ id: "stripecon_a",
946
+ organizationId: ORG_A,
947
+ mode: "test",
948
+ liveAt: null,
949
+ keyLast4: "aaaa",
950
+ webhookToken: "A".repeat(43),
951
+ webhookSecretCiphertext: null,
952
+ credentialCiphertext: encryptStripeConnectionValue(testKey(), ORG_A, "credential", KEY_A),
953
+ createdAt: now,
954
+ updatedAt: now,
955
+ });
956
+ expect(await getOfferingsConnectionState(ORG_A)).toMatchObject({ mode: "test", last4: "aaaa" });
957
+ });
958
+
959
+ it("sells an Organization with no stored row through the environment client, and no other", async () => {
960
+ process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
961
+ const owned = await stripeClientForOrganization(ORG_A);
962
+ expect(owned).toMatchObject({ kind: "ok", mode: "test" });
963
+ expect(await stripeClientForOrganization(ORG_B)).toEqual({ kind: "none" });
964
+ // The per-key factory is never used for the environment key.
965
+ expect(mockStripeClientForConnection).not.toHaveBeenCalled();
966
+ });
967
+
968
+ it("answers unusable when the owner's key is one the process refuses", async () => {
969
+ process.env["STRIPE_ENV_ORGANIZATION_ID"] = ORG_A;
970
+ mockGetStripeClient.mockImplementationOnce(() => {
971
+ throw new Error("refused");
972
+ });
973
+ expect(await stripeClientForOrganization(ORG_A)).toEqual({ kind: "unusable" });
974
+ });
975
+ });
@@ -189,6 +189,10 @@ function corruptConnection(): void {
189
189
  const noValidate = { validate: () => undefined };
190
190
 
191
191
  beforeEach(async () => {
192
+ // With the multi-tenant module and no STRIPE_ENV_ORGANIZATION_ID nobody owns the
193
+ // environment key (#9285 AC-8), so "no connection" stays no connection here.
194
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
195
+ delete process.env.STRIPE_ENV_ORGANIZATION_ID;
192
196
  for (const key of Object.keys(db.__db)) delete db.__db[key];
193
197
  jest.clearAllMocks();
194
198
  resetOfferingsRefreshStateForTests();
@@ -135,6 +135,10 @@ const rejecting = () =>
135
135
  });
136
136
 
137
137
  beforeEach(async () => {
138
+ // With the multi-tenant module and no STRIPE_ENV_ORGANIZATION_ID nobody owns the
139
+ // environment key (#9285 AC-8), so "no connection" stays no connection here.
140
+ process.env.AILK_ENABLED_MODULES = "multi-tenant";
141
+ delete process.env.STRIPE_ENV_ORGANIZATION_ID;
138
142
  for (const key of Object.keys(db.__db)) delete db.__db[key];
139
143
  mockLog.mockClear();
140
144
  db.workspaceOfferings.findUnique.mockClear();
@@ -117,6 +117,16 @@ export function isOfferingsProduct(product: CatalogProduct): boolean {
117
117
  && product.metadata.ailk_group.length > 0;
118
118
  }
119
119
 
120
+ /**
121
+ * An Offerings `plan` Product: `metadata.ailk_group` exactly `"plan"` — the
122
+ * normalizer's own group read, the stricter sibling of `isOfferingsProduct`.
123
+ * The Billing tab's plan checkout sells only a Price whose Product passes
124
+ * this (#9338 DD4), so an unmarked hub plan never sells from it.
125
+ */
126
+ export function isOfferingsPlanProduct(product: CatalogProduct): boolean {
127
+ return groupOf(product) === "plan";
128
+ }
129
+
120
130
  function metadataOf(product: CatalogProduct): Record<string, unknown> {
121
131
  return isRecord(product.metadata) ? product.metadata : {};
122
132
  }
@@ -13,17 +13,21 @@
13
13
  * key: that key sells the deployment's own products, not a spoke's (#9126).
14
14
  *
15
15
  * THE SALE. `startOfferingsCheckout` parses the lookup key by the one grammar
16
- * before any read (A3), authorizes it at click time against org-enabled ∩
16
+ * before any read (A3), refuses the hub's own plan keys at any period
17
+ * (#9338 DD5), authorizes it at click time against org-enabled ∩
17
18
  * workspace-shown from the rows — never the snapshot (A4), and for a
18
19
  * workspace saved per Price only a shown Price (#9070 D5) — resolves it live
19
20
  * with the connection's own client, sells the authorized Price id in an
20
21
  * anonymous hosted session stamped `{ site, ailk_offering }` by the server
21
22
  * (A6, A7), and records the session before it answers; a failed record
22
23
  * expires the session (A11). Every miss is one `unknown`; every failure one
23
- * `unavailable`; there is never a snapshot or env-key fallback (A5).
24
+ * `unavailable`; there is never a snapshot fallback (A5), and this path never
25
+ * sells on the environment key: an Organization that owns it checks out on the
26
+ * env path until it has a stored connection (#9296, #8936).
24
27
  *
25
28
  * THE READ. `readOfferingsCheckoutSession` reads a recorded session with its
26
- * record's own connection, never the env key or a caller value (A13).
29
+ * record's own connection, never a caller value (A13). Only a stored connection
30
+ * records a session (#9296), so this read never runs on the environment key.
27
31
  *
28
32
  * LOGGING (D11, T6). Only `workspaceId` or `organizationId`, an 8-character
29
33
  * session-id prefix, a fixed `reason`, and an error's class name. Never a
@@ -33,6 +37,7 @@
33
37
  import { logger } from "@working-theory/observability";
34
38
  import {
35
39
  OFFERINGS_NAME_MAX,
40
+ isPlanPurchaseKey,
36
41
  lookupKeyPeriodSchema,
37
42
  parseLookupKey,
38
43
  } from "@working-theory/validation";
@@ -55,7 +60,7 @@ import {
55
60
  recordCheckoutSession,
56
61
  } from "./checkout-session-record.js";
57
62
  import {
58
- getStripeConnectionSummary,
63
+ getOfferingsConnectionState,
59
64
  stripeClientForOrganization,
60
65
  } from "./connection.js";
61
66
  import { organizationIdOfWorkspace } from "./store.js";
@@ -106,10 +111,17 @@ export async function resolveOfferingsCheckoutPath(
106
111
  : { kind: "env" };
107
112
  const organizationId = await organizationIdOfWorkspace(workspaceId);
108
113
  if (organizationId === null) return noConnection;
109
- const summary = await getStripeConnectionSummary(organizationId);
114
+ const summary = await getOfferingsConnectionState(organizationId);
110
115
  if (summary === null) return noConnection;
111
116
  // Never the rows, which read a corrupt connection as an empty set (404).
112
117
  if (summary === "unreadable") return { kind: "unavailable" };
118
+ // An Organization on the environment key never takes the Offerings path
119
+ // (#9296): that path stamps `ailk_offering`, which the legacy webhook acks
120
+ // `offerings_path` without recording and the per-Organization receiver takes
121
+ // only for a stored connection's token, so a sale on the environment key
122
+ // would be charged and never fulfilled. Its checkout stays on the env path
123
+ // until environment-key fulfilment exists (#8936 stores the connection).
124
+ if (summary === "environment") return noConnection;
113
125
  return { kind: "offerings", workspaceId, organizationId };
114
126
  }
115
127
 
@@ -168,6 +180,12 @@ export async function startOfferingsCheckout(
168
180
  const parsed = parseLookupKey(productCode);
169
181
  if (parsed === null) return unknown();
170
182
 
183
+ // 1b. Never a hub plan (#9338 DD5): AILK's own Pro / Pro Plus, at any
184
+ // period, for every Organization. A sale here would stamp `ailk_offering`,
185
+ // which no billing path mints a licence for — the buyer would be charged
186
+ // with no key. Same 404, same log reason, before any read.
187
+ if (isPlanPurchaseKey(productCode)) return unknown();
188
+
171
189
  // 2. The connection's own client (K6) — before the rows, so a key that
172
190
  // cannot be used answers 502 whatever the rows hold.
173
191
  let connection: Awaited<ReturnType<typeof stripeClientForOrganization>>;
@@ -49,9 +49,11 @@ import {
49
49
  import { z } from "zod";
50
50
 
51
51
  import { config } from "../../config/index.js";
52
+ import { isModuleEnabled } from "../../config/modules.js";
52
53
  import { prisma } from "../../lib/prisma.js";
53
54
  import {
54
55
  classifyStripeKey,
56
+ getStripeClient,
55
57
  isProductionBuild,
56
58
  stripeClientForConnection,
57
59
  type StripeConnectionMode,
@@ -365,6 +367,65 @@ const viewSchema = z
365
367
  .refine((view) => (view.mode === "live") === (view.liveAt !== null), { path: ["liveAt"] });
366
368
  export type StripeConnectionView = z.infer<typeof viewSchema>;
367
369
 
370
+ /**
371
+ * The read-only source the GET answers when the Organization has NO stored
372
+ * connection but OWNS the Stripe key in this API process's environment
373
+ * (#9285 AC-3, AC-8): the deployment's own `STRIPE_SECRET_KEY`. It names the
374
+ * mode the key's class implies and nothing else — never the key, its last
375
+ * four or a webhook path — and a publishable or unrecognised value is no
376
+ * source at all. A stored connection always wins; the caller decides that,
377
+ * not this function.
378
+ */
379
+ export type StripeEnvironmentSource = { source: "environment"; mode: StripeConnectionMode };
380
+
381
+ /** The mode the process's own Stripe key implies, or `null` for no usable class. */
382
+ function environmentKeyMode(): StripeConnectionMode | null {
383
+ switch (classifyStripeKey(process.env["STRIPE_SECRET_KEY"])) {
384
+ case "restricted-test":
385
+ case "secret-test":
386
+ return "test";
387
+ case "restricted-live":
388
+ case "secret-live":
389
+ return "live";
390
+ default:
391
+ return null;
392
+ }
393
+ }
394
+
395
+ /**
396
+ * Whether this Organization owns the environment key (#9285 AC-8). Without
397
+ * the multi-tenant module a deployment has one Organization, and it owns the
398
+ * key. With it, only the Organization named by `STRIPE_ENV_ORGANIZATION_ID`
399
+ * does; unset, blank or naming no Organization, nobody does (fail closed), so
400
+ * one tenant's Offerings can never sell through another's account.
401
+ */
402
+ export function organizationOwnsEnvironmentStripeKey(organizationId: string): boolean {
403
+ if (!isModuleEnabled("multi-tenant")) return true;
404
+ const owner = process.env["STRIPE_ENV_ORGANIZATION_ID"]?.trim();
405
+ return owner !== undefined && owner !== "" && owner === organizationId;
406
+ }
407
+
408
+ /** The environment source for this Organization, or `null` when it has none to see. */
409
+ export function getEnvironmentStripeSource(organizationId: string): StripeEnvironmentSource | null {
410
+ if (!organizationOwnsEnvironmentStripeKey(organizationId)) return null;
411
+ const mode = environmentKeyMode();
412
+ return mode === null ? null : { source: "environment", mode };
413
+ }
414
+
415
+ /**
416
+ * The connection state Offerings sells on (#9285 AC-9): the stored summary
417
+ * first, else `"environment"` for an Organization that owns the environment
418
+ * key, else `null`. `"unreadable"` stays a corrupt stored row. The one
419
+ * "is this Organization connected" read every Offerings path shares.
420
+ */
421
+ export async function getOfferingsConnectionState(
422
+ organizationId: string,
423
+ ): Promise<StripeConnectionSummary | "unreadable" | "environment" | null> {
424
+ const stored = await getStripeConnectionSummary(organizationId);
425
+ if (stored !== null) return stored;
426
+ return getEnvironmentStripeSource(organizationId) === null ? null : "environment";
427
+ }
428
+
368
429
  /** What a write answers (D7): no `last4`, no `webhookPath` (SEC-8907-10). */
369
430
  export type StripeConnectionSaved = {
370
431
  mode: StripeConnectionMode;
@@ -837,13 +898,32 @@ export type StripeConnectionClient =
837
898
  | { kind: "unusable" }
838
899
  | { kind: "ok"; client: ConnectionClient; mode: StripeConnectionMode };
839
900
 
901
+ /**
902
+ * The environment key's client for the Organization that owns it (#9285
903
+ * AC-9), through `getStripeClient()` — the allow-list the env checkout path
904
+ * already meets (a test key, or the restricted live key the process admitted
905
+ * at boot). A key it refuses is `unusable`, not `none`: the Organization owns
906
+ * a key it cannot sell through.
907
+ */
908
+ function environmentClientFor(organizationId: string): StripeConnectionClient {
909
+ const source = getEnvironmentStripeSource(organizationId);
910
+ if (source === null) return { kind: "none" };
911
+ try {
912
+ return { kind: "ok", client: getStripeClient(), mode: source.mode };
913
+ } catch {
914
+ return { kind: "unusable" };
915
+ }
916
+ }
917
+
840
918
  /**
841
919
  * A client built from the Organization's own key, in its own mode — what
842
920
  * #8054's catalog source and #8861's checkout call. A live row on a
843
921
  * non-production build is `unusable` before any decrypt (D3); so is a key
844
922
  * that does not decrypt and one the factory refuses for the row's mode.
845
- * Nothing is cached beyond the call (C8), and there is never a fallback to
846
- * the deployment's env key.
923
+ * Nothing is cached beyond the call (C8). With no stored row, an
924
+ * Organization that owns the deployment's environment key (#9285 AC-8) is sold
925
+ * through it, by the same chokepoint the env checkout path uses; no other
926
+ * Organization ever falls back to it.
847
927
  */
848
928
  export async function stripeClientForOrganization(
849
929
  organizationId: string,
@@ -854,7 +934,7 @@ export async function stripeClientForOrganization(
854
934
  select: { credentialCiphertext: true, mode: true, liveAt: true },
855
935
  }),
856
936
  );
857
- if (row === null) return { kind: "none" };
937
+ if (row === null) return environmentClientFor(organizationId);
858
938
  if (!isMode(row.mode)) {
859
939
  logUnreadableRow(organizationId);
860
940
  return { kind: "unusable" };
@@ -59,7 +59,7 @@ import {
59
59
  type NormalizedCatalog,
60
60
  type OfferingsCatalogSource,
61
61
  } from "./catalog.js";
62
- import { getStripeConnectionSummary } from "./connection.js";
62
+ import { getOfferingsConnectionState } from "./connection.js";
63
63
  import {
64
64
  OfferingsShapeError,
65
65
  organizationIdOfWorkspace,
@@ -81,7 +81,7 @@ export {
81
81
  type OfferingsCatalog,
82
82
  type OfferingsCatalogSource,
83
83
  } from "./catalog.js";
84
- export { getStripeConnectionSummary } from "./connection.js";
84
+ export { getOfferingsConnectionState, getStripeConnectionSummary } from "./connection.js";
85
85
  export { OfferingsShapeError } from "./store.js";
86
86
 
87
87
  /** The snapshot is served for at most 24 hours from `snapshotAt` (C5). */
@@ -268,7 +268,7 @@ export async function getOrgEnabledProducts(
268
268
  sourceFor: OfferingsSourceFor,
269
269
  { now = new Date() }: { now?: Date } = {},
270
270
  ): Promise<OrgEnabledProducts | null> {
271
- const connection = await getStripeConnectionSummary(organizationId);
271
+ const connection = await getOfferingsConnectionState(organizationId);
272
272
  if (connection === null) return null;
273
273
  const productIds = await readEnabledProductIds(organizationId);
274
274
  if (connection === "unreadable") {
@@ -352,7 +352,7 @@ export async function listOrgProducts(
352
352
  sourceFor: OfferingsSourceFor,
353
353
  { now = new Date() }: { now?: Date } = {},
354
354
  ): Promise<OrgProductList> {
355
- const connection = await getStripeConnectionSummary(organizationId);
355
+ const connection = await getOfferingsConnectionState(organizationId);
356
356
  if (connection === null) return { connection: "none" };
357
357
  if (connection === "unreadable") return { connection: "unreadable" };
358
358
  const enabled = await readEnabledProductIds(organizationId);
@@ -378,7 +378,7 @@ export async function setOrgEnabledProducts(
378
378
  validate,
379
379
  }: { now?: Date; validate: (list: ConnectedOrgProductList) => void },
380
380
  ): Promise<SetOrgEnabledResult> {
381
- const connection = await getStripeConnectionSummary(organizationId);
381
+ const connection = await getOfferingsConnectionState(organizationId);
382
382
  if (connection === null) return { kind: "not_connected" };
383
383
  if (connection === "unreadable") return { kind: "connection_unreadable" };
384
384
  const catalog = await catalogOrSourceError(organizationId, sourceFor, now);
@@ -443,7 +443,7 @@ async function resolveWorkspace(
443
443
  ): Promise<{ missing: "no_connection" | "no_selection" } | ResolvedWorkspace> {
444
444
  const organizationId = await organizationIdOfWorkspace(workspaceId);
445
445
  if (organizationId === null) return { missing: "no_selection" };
446
- const connection = await getStripeConnectionSummary(organizationId);
446
+ const connection = await getOfferingsConnectionState(organizationId);
447
447
  if (connection === null) return { missing: "no_connection" };
448
448
  // An unreadable connection serves nothing, with or without a selection
449
449
  // row, and never reads as unconnected (SEC-PR8955-R2-1).