create-ailk 0.22.0 → 0.23.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 (238) hide show
  1. package/component-catalog.json +149 -5
  2. package/component-catalog.md +7 -6
  3. package/dist/add-module.js +11 -9
  4. package/dist/add-route.d.ts +55 -1
  5. package/dist/add-route.js +294 -12
  6. package/dist/cli.js +27 -10
  7. package/dist/derive-architecture.d.ts +7 -5
  8. package/dist/derive-architecture.js +9 -32
  9. package/dist/lib/design-checks.js +4 -2
  10. package/dist/lib/env-catalog.js +0 -4
  11. package/dist/lib/npm-auth.d.ts +9 -0
  12. package/dist/lib/npm-auth.js +48 -0
  13. package/dist/lib/scaffold-render-sweep.d.ts +3 -2
  14. package/dist/lib/scaffold-render-sweep.js +5 -8
  15. package/dist/module-architecture.js +98 -10
  16. package/dist/paid-sections.d.ts +21 -2
  17. package/dist/paid-sections.js +69 -7
  18. package/dist/seed-flows.d.ts +4 -1
  19. package/dist/seed-flows.js +50 -4
  20. package/dist/spoke-checkout.d.ts +38 -35
  21. package/dist/spoke-checkout.js +66 -229
  22. package/dist/starter-grid.d.ts +0 -3
  23. package/dist/starter-grid.js +24 -14
  24. package/dist/surfaces.js +33 -12
  25. package/dist/sync-routes.d.ts +38 -1
  26. package/dist/sync-routes.js +184 -11
  27. package/dist/template-record.d.ts +1 -1
  28. package/dist/template-record.js +3 -2
  29. package/dist/update.js +10 -0
  30. package/package.json +3 -3
  31. package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
  32. package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
  33. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
  34. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
  35. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
  36. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
  37. package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
  38. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
  39. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
  40. package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  41. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
  42. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
  43. package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  44. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
  45. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
  46. package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
  47. package/templates/.env.example +9 -7
  48. package/templates/.github/workflows/ci.yml +13 -0
  49. package/templates/README.md +1 -0
  50. package/templates/_gitignore +4 -0
  51. package/templates/apps/api/.env.example +27 -0
  52. package/templates/apps/api/CLAUDE.md +77 -16
  53. package/templates/apps/api/package.json +1 -0
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
  55. package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
  56. package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
  57. package/templates/apps/api/src/config/index.ts +109 -0
  58. package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
  59. package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
  60. package/templates/apps/api/src/lib/error-serializer.ts +74 -7
  61. package/templates/apps/api/src/lib/stripe.ts +74 -4
  62. package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
  63. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
  64. package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
  65. package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
  66. package/templates/apps/api/src/openapi/spec.ts +10 -7
  67. package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
  68. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
  69. package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
  70. package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
  71. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
  72. package/templates/apps/api/src/routes/checkout/session.ts +57 -25
  73. package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
  74. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
  75. package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
  76. package/templates/apps/api/src/routes/consent/account.ts +4 -2
  77. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
  78. package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
  79. package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
  80. package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
  81. package/templates/apps/api/src/routes/content/index.ts +16 -2
  82. package/templates/apps/api/src/routes/content/offerings.ts +184 -0
  83. package/templates/apps/api/src/routes/content/pricing.ts +176 -0
  84. package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
  85. package/templates/apps/api/src/routes/leads/index.ts +17 -14
  86. package/templates/apps/api/src/routes/webhooks/README.md +66 -0
  87. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
  88. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
  89. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
  90. package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
  91. package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
  92. package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
  93. package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
  94. package/templates/apps/api/src/server.ts +140 -51
  95. package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
  96. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
  97. package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
  98. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
  99. package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
  100. package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
  101. package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
  102. package/templates/apps/api/src/services/entitlements.ts +157 -1
  103. package/templates/apps/api/src/services/lead-routing.ts +113 -58
  104. package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
  105. package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
  106. package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
  107. package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
  108. package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
  109. package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
  110. package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
  111. package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
  112. package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
  113. package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
  114. package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
  115. package/templates/apps/api/src/services/offerings/index.ts +614 -0
  116. package/templates/apps/api/src/services/offerings/source.ts +140 -0
  117. package/templates/apps/api/src/services/offerings/store.ts +150 -0
  118. package/templates/apps/api/src/services/person.ts +124 -9
  119. package/templates/apps/api/src/services/plan-offerings.ts +96 -30
  120. package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
  121. package/templates/apps/mcp/CLAUDE.md +22 -14
  122. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
  123. package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
  124. package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
  125. package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
  126. package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
  127. package/templates/apps/mcp/__tests__/server.test.ts +13 -11
  128. package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
  129. package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
  130. package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
  131. package/templates/apps/mcp/src/server.ts +5 -0
  132. package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
  133. package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
  134. package/templates/apps/mcp/src/tools/purchase.ts +30 -2
  135. package/templates/apps/mcp/src/workspace-server.ts +15 -0
  136. package/templates/apps/web/.env.example +10 -31
  137. package/templates/apps/web/CLAUDE.md +315 -42
  138. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
  139. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
  140. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
  141. package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
  142. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
  143. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
  144. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
  145. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
  146. package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
  147. package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
  148. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
  149. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
  150. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
  151. package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
  152. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
  153. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
  154. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
  155. package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
  156. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
  157. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
  158. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
  159. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
  160. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
  161. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
  162. package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
  163. package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
  164. package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
  165. package/templates/apps/web/app/[locale]/layout.tsx +55 -4
  166. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
  167. package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
  168. package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
  169. package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
  170. package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
  171. package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
  172. package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
  173. package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
  174. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
  175. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
  176. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
  177. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
  178. package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
  179. package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
  180. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
  181. package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
  182. package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
  183. package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
  184. package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
  185. package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
  186. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
  187. package/templates/apps/web/lib/auth-routes.ts +36 -2
  188. package/templates/apps/web/lib/checkout-config.ts +9 -20
  189. package/templates/apps/web/lib/deck-page.ts +1 -0
  190. package/templates/apps/web/lib/deliverable-grant.ts +18 -0
  191. package/templates/apps/web/lib/in-page-magnet.ts +416 -0
  192. package/templates/apps/web/lib/magnet-routes.ts +42 -0
  193. package/templates/apps/web/lib/modules.generated.ts +9 -0
  194. package/templates/apps/web/lib/newsletter.actions.ts +202 -0
  195. package/templates/apps/web/lib/offerings-pricing.ts +326 -0
  196. package/templates/apps/web/lib/posting-title.ts +17 -0
  197. package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
  198. package/templates/apps/web/messages/en.json +49 -15
  199. package/templates/apps/web/middleware.ts +12 -2
  200. package/templates/apps/web/security-headers.config.ts +17 -0
  201. package/templates/content/en/BlogList/blog.mdx +4 -71
  202. package/templates/content/en/BookCall/book-a-call.mdx +1 -41
  203. package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
  204. package/templates/content/en/BookCall/onsite-service.mdx +1 -17
  205. package/templates/content/en/BookCall/start-project.mdx +1 -17
  206. package/templates/content/en/ContactPage/contact.mdx +6 -63
  207. package/templates/content/en/HomePage/home.mdx +3 -21
  208. package/templates/database/CHANGELOG.md +31 -0
  209. package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
  210. package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
  211. package/templates/database/__tests__/offerings-migration.test.ts +197 -0
  212. package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
  213. package/templates/database/__tests__/offerings.db.test.ts +303 -0
  214. package/templates/database/__tests__/seed.test.ts +31 -1
  215. package/templates/database/auth/schema.prisma +4 -0
  216. package/templates/database/config/schema.prisma +91 -0
  217. package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
  218. package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
  219. package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
  220. package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
  221. package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
  222. package/templates/database/package.json +1 -1
  223. package/templates/database/seed.ts +30 -2
  224. package/versions.json +35 -35
  225. package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
  226. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
  227. package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
  228. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
  229. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
  230. package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
  231. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
  232. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
  233. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
  234. package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
  235. package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
  236. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
  237. package/templates/apps/web/lib/checkout.ts +0 -93
  238. package/templates/apps/web/lib/meeting-slugs.ts +0 -63
@@ -0,0 +1,385 @@
1
+ /**
2
+ * The Offerings checkout (#8931, spec
3
+ * docs/specs/8931-offerings-checkout-authorization-server-side-spec.md D1,
4
+ * D2, D5, D7, D9, D11; contract
5
+ * docs/specs/8879-the-offerings-security-contract-key-spec.md A1–A9, A11,
6
+ * A13, G1, G3, G6, K6).
7
+ *
8
+ * THE PATH. `resolveOfferingsCheckoutPath` decides, from the server-resolved
9
+ * workspace and its Organization's connection alone, whether a checkout
10
+ * request takes the Offerings path, the deployment's env-key path, or
11
+ * neither. No request body field names a workspace or an Organization (A1).
12
+ * A spoke's workspace with no connection is `unconnected`, never the env
13
+ * key: that key sells the deployment's own products, not a spoke's (#9126).
14
+ *
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 ∩
17
+ * workspace-shown from the rows — never the snapshot (A4), and for a
18
+ * workspace saved per Price only a shown Price (#9070 D5) — resolves it live
19
+ * with the connection's own client, sells the authorized Price id in an
20
+ * anonymous hosted session stamped `{ site, ailk_offering }` by the server
21
+ * (A6, A7), and records the session before it answers; a failed record
22
+ * 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
+ *
25
+ * THE READ. `readOfferingsCheckoutSession` reads a recorded session with its
26
+ * record's own connection, never the env key or a caller value (A13).
27
+ *
28
+ * LOGGING (D11, T6). Only `workspaceId` or `organizationId`, an 8-character
29
+ * session-id prefix, a fixed `reason`, and an error's class name. Never a
30
+ * lookup key, a Price or Product id, Stripe text, a URL, a full session id
31
+ * or an error message.
32
+ */
33
+ import { logger } from "@working-theory/observability";
34
+ import {
35
+ OFFERINGS_NAME_MAX,
36
+ lookupKeyPeriodSchema,
37
+ parseLookupKey,
38
+ } from "@working-theory/validation";
39
+ import type { FastifyRequest } from "fastify";
40
+
41
+ import {
42
+ createCheckoutSessionForPrice,
43
+ expireCheckoutSessionWith,
44
+ findActivePriceByLookupKey,
45
+ retrieveCheckoutSession,
46
+ type RetrieveCheckoutSessionOutput,
47
+ UnknownSessionError,
48
+ } from "../../lib/stripe.js";
49
+ import { resolvePublicWorkspace } from "../public-site-resolver.js";
50
+
51
+ import { normalizeCatalog, normalizeText } from "./catalog.js";
52
+ import {
53
+ CheckoutSessionRecordError,
54
+ readCheckoutSessionRecord,
55
+ recordCheckoutSession,
56
+ } from "./checkout-session-record.js";
57
+ import {
58
+ getStripeConnectionSummary,
59
+ stripeClientForOrganization,
60
+ } from "./connection.js";
61
+ import { organizationIdOfWorkspace } from "./store.js";
62
+
63
+ import { getSellableProductIds } from "./index.js";
64
+
65
+ /** One seller's Offerings checkouts a minute, across every caller (D4). */
66
+ export const OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE = 60;
67
+
68
+ /** The resolver's own header; its presence is what makes a refusal a refused key (D2). */
69
+ const SPOKE_KEY_HEADER = "x-ailk-spoke-key";
70
+
71
+ export type OfferingsCheckoutPath =
72
+ | { kind: "env" }
73
+ | { kind: "refused" }
74
+ | { kind: "unavailable" }
75
+ | { kind: "unconnected"; workspaceId: string }
76
+ | { kind: "offerings"; workspaceId: string; organizationId: string };
77
+
78
+ /**
79
+ * Which path a checkout request takes (D2). Rejects when a read rejects —
80
+ * the resolver's, the Organization's or the connection summary's — so a
81
+ * workspace it could not read never falls through to the env key.
82
+ */
83
+ export async function resolveOfferingsCheckoutPath(
84
+ request: FastifyRequest,
85
+ ): Promise<OfferingsCheckoutPath> {
86
+ const site = await resolvePublicWorkspace(request);
87
+ if (site.kind === "not-required") return { kind: "env" };
88
+ if (site.kind === "unresolved") {
89
+ // A keyless call on a hub that requires a key is also `spoke-refused`;
90
+ // only a presented key that was refused is `refused` (A2, C6).
91
+ return site.reason === "spoke-refused" &&
92
+ request.headers[SPOKE_KEY_HEADER] !== undefined
93
+ ? { kind: "refused" }
94
+ : { kind: "env" };
95
+ }
96
+
97
+ const { workspaceId } = site;
98
+ // A keyless call resolves only where no spoke key is required (a deployment
99
+ // that is not a hub, or a hub's AILK_HUB_REQUIRE_SPOKE_KEY=off opt-out),
100
+ // whose env key sells its own products; a presented key resolved only to
101
+ // its spoke's workspace, which sells on its own connection or not at all
102
+ // (#9126).
103
+ const noConnection: OfferingsCheckoutPath =
104
+ request.headers[SPOKE_KEY_HEADER] !== undefined
105
+ ? { kind: "unconnected", workspaceId }
106
+ : { kind: "env" };
107
+ const organizationId = await organizationIdOfWorkspace(workspaceId);
108
+ if (organizationId === null) return noConnection;
109
+ const summary = await getStripeConnectionSummary(organizationId);
110
+ if (summary === null) return noConnection;
111
+ // Never the rows, which read a corrupt connection as an empty set (404).
112
+ if (summary === "unreadable") return { kind: "unavailable" };
113
+ return { kind: "offerings", workspaceId, organizationId };
114
+ }
115
+
116
+ export interface OfferingsCheckoutInput {
117
+ workspaceId: string;
118
+ organizationId: string;
119
+ productCode: string;
120
+ successUrl: string;
121
+ cancelUrl: string;
122
+ quantity: number;
123
+ }
124
+
125
+ export type OfferingsCheckoutOutcome =
126
+ | { kind: "created"; sessionId: string; url: string }
127
+ | { kind: "unknown" }
128
+ | { kind: "unavailable" };
129
+
130
+ const UNKNOWN = { kind: "unknown" } as const;
131
+ const UNAVAILABLE = { kind: "unavailable" } as const;
132
+
133
+ function errName(err: unknown): string {
134
+ return err instanceof Error ? err.name : "unknown";
135
+ }
136
+
137
+ /**
138
+ * Authorize the key, sell its Price and record the session (D5–D7). Stops at
139
+ * the first step that fails; never rejects.
140
+ */
141
+ export async function startOfferingsCheckout(
142
+ input: OfferingsCheckoutInput,
143
+ ): Promise<OfferingsCheckoutOutcome> {
144
+ const { workspaceId, organizationId, productCode } = input;
145
+ const unknown = (): OfferingsCheckoutOutcome => {
146
+ logger.info(
147
+ { workspaceId, reason: "offerings_unknown_product" },
148
+ "[offerings] checkout key not sellable",
149
+ );
150
+ return UNKNOWN;
151
+ };
152
+ const unavailable = (
153
+ reason: string,
154
+ err?: unknown,
155
+ ): OfferingsCheckoutOutcome => {
156
+ logger.error(
157
+ {
158
+ workspaceId,
159
+ reason,
160
+ ...(err !== undefined && { errName: errName(err) }),
161
+ },
162
+ "[offerings] checkout unavailable",
163
+ );
164
+ return UNAVAILABLE;
165
+ };
166
+
167
+ // 1. Grammar first (A3): no read and no Stripe call for a key G1 refuses.
168
+ const parsed = parseLookupKey(productCode);
169
+ if (parsed === null) return unknown();
170
+
171
+ // 2. The connection's own client (K6) — before the rows, so a key that
172
+ // cannot be used answers 502 whatever the rows hold.
173
+ let connection: Awaited<ReturnType<typeof stripeClientForOrganization>>;
174
+ try {
175
+ connection = await stripeClientForOrganization(organizationId);
176
+ } catch (err) {
177
+ return unavailable("offerings_connection_unusable", err);
178
+ }
179
+ if (connection.kind !== "ok")
180
+ return unavailable("offerings_connection_unusable");
181
+ const { client, mode } = connection;
182
+
183
+ // 3. The rows (A4): org-enabled ∩ workspace-shown, never the snapshot.
184
+ let sellable: Awaited<ReturnType<typeof getSellableProductIds>>;
185
+ try {
186
+ sellable = await getSellableProductIds(workspaceId);
187
+ } catch (err) {
188
+ return unavailable("offerings_rows_failed", err);
189
+ }
190
+ if (sellable === null || sellable.productIds.length === 0) return unknown();
191
+ if (sellable.organizationId !== organizationId) {
192
+ return unavailable("offerings_rows_failed");
193
+ }
194
+
195
+ // 4. The live lookup (A4), on the connection's client.
196
+ let price: Awaited<ReturnType<typeof findActivePriceByLookupKey>>;
197
+ try {
198
+ price = await findActivePriceByLookupKey(client, productCode);
199
+ } catch (err) {
200
+ return unavailable("offerings_price_lookup_failed", err);
201
+ }
202
+ if (price === null) return unknown();
203
+
204
+ // 5. The intersection and the period (A4, G3): the Price's own Product
205
+ // must be sellable here — and, for a workspace saved per Price, the Price
206
+ // itself shown (#9070 D5) — and the one normalizer must keep this exact
207
+ // Price under this key and period.
208
+ const product: unknown = price.product;
209
+ if (typeof product !== "object" || product === null) return unknown();
210
+ const productId = (product as { id?: unknown }).id;
211
+ if (
212
+ typeof productId !== "string" ||
213
+ !sellable.productIds.includes(productId) ||
214
+ (sellable.priceIds !== null && !sellable.priceIds.includes(price.id))
215
+ ) {
216
+ return unknown();
217
+ }
218
+ const offering = normalizeCatalog({
219
+ products: [product],
220
+ prices: [price],
221
+ }).offerings.find((o) => o.productId === productId);
222
+ const authorized = offering?.prices.some(
223
+ (p) =>
224
+ p.priceId === price.id &&
225
+ p.lookupKey === productCode &&
226
+ p.period === parsed.period,
227
+ );
228
+ if (!authorized) return unknown();
229
+
230
+ // 6. The sale (A6, A7): the authorized Price id, anonymous, stamped by the
231
+ // server, its mode from the period step 5 checked against the Price.
232
+ const metadata = { site: workspaceId, ailk_offering: productCode };
233
+ let created: { sessionId: string; url: string };
234
+ try {
235
+ created = await createCheckoutSessionForPrice(client, {
236
+ priceId: price.id,
237
+ mode:
238
+ parsed.period === lookupKeyPeriodSchema.enum.once
239
+ ? "payment"
240
+ : "subscription",
241
+ successUrl: input.successUrl,
242
+ cancelUrl: input.cancelUrl,
243
+ quantity: input.quantity,
244
+ allowPromotionCodes: true,
245
+ customerCreation: "always",
246
+ metadata,
247
+ });
248
+ } catch (err) {
249
+ return unavailable("offerings_session_create_failed", err);
250
+ }
251
+
252
+ // 7. The record (A11), before the URL leaves the server. A failed record
253
+ // expires the session on the same client: an unrecorded session is one the
254
+ // webhook never fulfils, so no buyer may reach it.
255
+ const sessionIdPrefix = created.sessionId.substring(0, 8);
256
+ try {
257
+ await recordCheckoutSession({
258
+ sessionId: created.sessionId,
259
+ workspaceId,
260
+ organizationId,
261
+ priceId: price.id,
262
+ lookupKey: productCode,
263
+ mode,
264
+ });
265
+ } catch (err) {
266
+ logger.error(
267
+ {
268
+ workspaceId,
269
+ sessionIdPrefix,
270
+ reason: "offerings_record_failed",
271
+ errName: errName(err),
272
+ ...(err instanceof CheckoutSessionRecordError && {
273
+ recordReason: err.reason,
274
+ }),
275
+ },
276
+ "[offerings] checkout session record failed; expiring the session",
277
+ );
278
+ try {
279
+ await expireCheckoutSessionWith(client, created.sessionId);
280
+ } catch (expireErr) {
281
+ logger.error(
282
+ {
283
+ workspaceId,
284
+ sessionIdPrefix,
285
+ reason: "offerings_expire_failed",
286
+ errName: errName(expireErr),
287
+ },
288
+ "[offerings] unrecorded checkout session could not be expired",
289
+ );
290
+ }
291
+ return UNAVAILABLE;
292
+ }
293
+
294
+ return { kind: "created", sessionId: created.sessionId, url: created.url };
295
+ }
296
+
297
+ export type OfferingsSessionRead =
298
+ | { kind: "env" }
299
+ | { kind: "read"; session: RetrieveCheckoutSessionOutput }
300
+ | { kind: "unknown" }
301
+ | { kind: "unavailable" };
302
+
303
+ /**
304
+ * Read a session the Offerings checkout recorded, with its record's own
305
+ * connection (D9, A13). No record is `env`; a record that cannot be read, or
306
+ * a connection that is gone, unusable or in another mode, is `unavailable` —
307
+ * never the env path. Never rejects.
308
+ */
309
+ export async function readOfferingsCheckoutSession(
310
+ sessionId: string,
311
+ ): Promise<OfferingsSessionRead> {
312
+ const sessionIdPrefix = sessionId.substring(0, 8);
313
+
314
+ let record: Awaited<ReturnType<typeof readCheckoutSessionRecord>>;
315
+ try {
316
+ record = await readCheckoutSessionRecord(sessionId);
317
+ } catch (err) {
318
+ logger.error(
319
+ {
320
+ sessionIdPrefix,
321
+ reason: "offerings_record_failed",
322
+ errName: errName(err),
323
+ },
324
+ "[offerings] checkout session record read failed",
325
+ );
326
+ return UNAVAILABLE;
327
+ }
328
+ if (record === null) return { kind: "env" };
329
+ if (record === "unreadable") return UNAVAILABLE;
330
+
331
+ const { organizationId } = record;
332
+ let connection: Awaited<ReturnType<typeof stripeClientForOrganization>>;
333
+ try {
334
+ connection = await stripeClientForOrganization(organizationId);
335
+ } catch (err) {
336
+ logger.error(
337
+ {
338
+ organizationId,
339
+ reason: "offerings_connection_unusable",
340
+ errName: errName(err),
341
+ },
342
+ "[offerings] checkout session read unavailable",
343
+ );
344
+ return UNAVAILABLE;
345
+ }
346
+ // C7: only the connection the session was made on, in that mode.
347
+ if (connection.kind !== "ok" || connection.mode !== record.mode) {
348
+ logger.error(
349
+ { organizationId, reason: "offerings_connection_unusable" },
350
+ "[offerings] checkout session read unavailable",
351
+ );
352
+ return UNAVAILABLE;
353
+ }
354
+
355
+ let session: RetrieveCheckoutSessionOutput;
356
+ try {
357
+ session = await retrieveCheckoutSession(
358
+ { sessionId },
359
+ { client: connection.client },
360
+ );
361
+ } catch (err) {
362
+ if (err instanceof UnknownSessionError) return UNKNOWN;
363
+ logger.error(
364
+ {
365
+ organizationId,
366
+ reason: "offerings_session_read_failed",
367
+ errName: errName(err),
368
+ },
369
+ "[offerings] checkout session read unavailable",
370
+ );
371
+ return UNAVAILABLE;
372
+ }
373
+
374
+ // The seller's Product names are Stripe text: served only as T1 bounds them.
375
+ return {
376
+ kind: "read",
377
+ session: {
378
+ ...session,
379
+ lineItems: session.lineItems.map((line) => ({
380
+ ...line,
381
+ description: normalizeText(line.description, OFFERINGS_NAME_MAX) ?? "",
382
+ })),
383
+ },
384
+ };
385
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * The Offerings Checkout session record (#8930, spec
3
+ * docs/specs/8930-the-offerings-checkout-session-record-spec.md D4, D5, D7;
4
+ * contract docs/specs/8879-the-offerings-security-contract-key-spec.md A11,
5
+ * W5, A13) — the ONE file naming the OfferingsCheckoutSession delegate.
6
+ *
7
+ * WRITE ONCE. The checkout route records `{ sessionId, workspaceId,
8
+ * organizationId, priceId, lookupKey, mode }` before it answers, and the
9
+ * per-Organization webhook and the session read act only on a session
10
+ * recorded here — never on metadata alone. One `create` and one `findUnique`
11
+ * by Checkout Session id; nothing here updates, upserts, deletes or lists, so
12
+ * no record is rebound to another workspace and none is enumerated.
13
+ *
14
+ * `null` means one thing: the database holds no row for that id (or the id
15
+ * has a shape no row can hold). A database error rejects `read_failed`, and a
16
+ * row that fails the parse answers `"unreadable"` — neither is ever `null`,
17
+ * because a caller that read `null` would treat a paid session as foreign.
18
+ *
19
+ * ERRORS AND LOGGING (D7, contract T6). A `CheckoutSessionRecordError` carries
20
+ * a fixed message per reason and no `cause`, so no Prisma or Postgres text — a
21
+ * CHECK violation prints the failing row — reaches a log line or an HTTP
22
+ * error. Logs carry only a fixed `reason` (and the workspace on a failed
23
+ * write); never a session id, Price id, lookup key or mode.
24
+ */
25
+ import { idRegex } from "@working-theory/ids";
26
+ import { logger } from "@working-theory/observability";
27
+ import {
28
+ offeringsStripeIdSchema,
29
+ parseLookupKey,
30
+ } from "@working-theory/validation";
31
+ import { z } from "zod";
32
+
33
+ import { prisma } from "../../lib/prisma.js";
34
+ import { isUniqueViolation } from "../default-tenant.js";
35
+
36
+ import { organizationIdOfWorkspace } from "./store.js";
37
+
38
+ export type CheckoutSessionMode = "test" | "live";
39
+
40
+ export interface CheckoutSessionRecordInput {
41
+ sessionId: string;
42
+ workspaceId: string;
43
+ organizationId: string;
44
+ priceId: string;
45
+ lookupKey: string;
46
+ mode: CheckoutSessionMode;
47
+ }
48
+
49
+ export interface CheckoutSessionRecord extends CheckoutSessionRecordInput {
50
+ createdAt: Date;
51
+ }
52
+
53
+ export type CheckoutSessionRecordFailure =
54
+ | "invalid"
55
+ | "workspace_mismatch"
56
+ | "conflict"
57
+ | "write_failed"
58
+ | "read_failed";
59
+
60
+ const FAILURE_MESSAGES: Record<CheckoutSessionRecordFailure, string> = {
61
+ invalid: "The Checkout session record is malformed",
62
+ workspace_mismatch:
63
+ "The Checkout session record names a workspace outside its Organization",
64
+ conflict: "The Checkout session is already recorded",
65
+ write_failed: "The Checkout session record could not be written",
66
+ read_failed: "The Checkout session record could not be read",
67
+ };
68
+
69
+ export class CheckoutSessionRecordError extends Error {
70
+ readonly reason: CheckoutSessionRecordFailure;
71
+
72
+ constructor(reason: CheckoutSessionRecordFailure) {
73
+ super(FAILURE_MESSAGES[reason]);
74
+ this.name = "CheckoutSessionRecordError";
75
+ this.reason = reason;
76
+ }
77
+ }
78
+
79
+ /** A Checkout Session id: the migration's `_session_id_format` CHECK body. */
80
+ const SESSION_ID_PATTERN = /^cs_(test|live)_[A-Za-z0-9]{1,250}$/;
81
+
82
+ /** Every column the migration CHECKs, mirrored so a refused write never reaches Postgres. */
83
+ const recordShape = {
84
+ sessionId: z.string().regex(SESSION_ID_PATTERN),
85
+ workspaceId: z.string().regex(idRegex("workspace")),
86
+ organizationId: z.string().regex(idRegex("organization")),
87
+ priceId: offeringsStripeIdSchema,
88
+ lookupKey: z.string().refine((key) => parseLookupKey(key) !== null),
89
+ mode: z.enum(["test", "live"]),
90
+ };
91
+
92
+ /** The `_session_mode_agree` CHECK: a live key makes a `cs_live_` session. */
93
+ const modeAgrees = (record: {
94
+ sessionId: string;
95
+ mode: CheckoutSessionMode;
96
+ }): boolean =>
97
+ (record.mode === "live") === record.sessionId.startsWith("cs_live_");
98
+
99
+ const recordInputSchema = z.object(recordShape).strict().refine(modeAgrees);
100
+ const recordRowSchema = z
101
+ .object({ ...recordShape, createdAt: z.date() })
102
+ .strict()
103
+ .refine(modeAgrees);
104
+
105
+ /** The record's columns, never its `id`. */
106
+ const RECORD_SELECT = {
107
+ sessionId: true,
108
+ workspaceId: true,
109
+ organizationId: true,
110
+ priceId: true,
111
+ lookupKey: true,
112
+ mode: true,
113
+ createdAt: true,
114
+ } as const;
115
+
116
+ /**
117
+ * Record one Checkout Session, once, for a workspace of the named
118
+ * Organization. Rejects `invalid` (a malformed input), `workspace_mismatch`
119
+ * (no such workspace, or another Organization's), `conflict` (the session is
120
+ * already recorded; the first record stands) or `write_failed`.
121
+ */
122
+ export async function recordCheckoutSession(
123
+ input: CheckoutSessionRecordInput,
124
+ ): Promise<void> {
125
+ const parsed = recordInputSchema.safeParse(input);
126
+ if (!parsed.success) throw new CheckoutSessionRecordError("invalid");
127
+ const data = parsed.data;
128
+
129
+ let organizationId: string | null;
130
+ try {
131
+ organizationId = await organizationIdOfWorkspace(data.workspaceId);
132
+ } catch {
133
+ throw writeFailed(data.workspaceId);
134
+ }
135
+ if (organizationId !== data.organizationId) {
136
+ throw new CheckoutSessionRecordError("workspace_mismatch");
137
+ }
138
+
139
+ try {
140
+ await prisma.offeringsCheckoutSession.create({
141
+ data,
142
+ select: { sessionId: true },
143
+ });
144
+ } catch (err) {
145
+ if (isUniqueViolation(err))
146
+ throw new CheckoutSessionRecordError("conflict");
147
+ throw writeFailed(data.workspaceId);
148
+ }
149
+ }
150
+
151
+ /**
152
+ * The record of one Checkout Session: the record, `"unreadable"` for a row
153
+ * that fails the parse, or `null` when no row holds that id. Rejects
154
+ * `read_failed` when the database read fails.
155
+ */
156
+ export async function readCheckoutSessionRecord(
157
+ sessionId: unknown,
158
+ ): Promise<CheckoutSessionRecord | "unreadable" | null> {
159
+ if (typeof sessionId !== "string" || !SESSION_ID_PATTERN.test(sessionId))
160
+ return null;
161
+
162
+ let row: unknown;
163
+ try {
164
+ row = await prisma.offeringsCheckoutSession.findUnique({
165
+ where: { sessionId },
166
+ select: RECORD_SELECT,
167
+ });
168
+ } catch {
169
+ logger.error(
170
+ { reason: "checkout_record_read_failed" },
171
+ "[offerings] checkout session record read failed",
172
+ );
173
+ throw new CheckoutSessionRecordError("read_failed");
174
+ }
175
+ if (row === null) return null;
176
+
177
+ const record = recordRowSchema.safeParse(row);
178
+ if (record.success) return record.data;
179
+ logger.error(
180
+ { reason: "checkout_record_unreadable" },
181
+ "[offerings] checkout session record is unreadable",
182
+ );
183
+ return "unreadable";
184
+ }
185
+
186
+ function writeFailed(workspaceId: string): CheckoutSessionRecordError {
187
+ logger.error(
188
+ { workspaceId, reason: "checkout_record_write_failed" },
189
+ "[offerings] checkout session record write failed",
190
+ );
191
+ return new CheckoutSessionRecordError("write_failed");
192
+ }