create-ailk 0.24.0 → 0.26.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.
- package/README.md +53 -22
- package/component-catalog.json +3 -3
- package/component-catalog.md +3 -3
- package/dist/add-module.js +4 -2
- package/dist/add-route.d.ts +12 -1
- package/dist/add-route.js +31 -3
- package/dist/cli.js +12 -7
- package/dist/derive-architecture.d.ts +3 -4
- package/dist/derive-architecture.js +65 -29
- package/dist/lib/design-checks.d.ts +9 -4
- package/dist/lib/design-checks.js +13 -7
- package/dist/lib/extract-module-bundle.js +29 -2
- package/dist/lib/fetch-template-snapshot.d.ts +2 -1
- package/dist/lib/fetch-template-snapshot.js +8 -3
- package/dist/lib/import-graph.d.ts +1 -1
- package/dist/lib/import-graph.js +4 -4
- package/dist/lib/module-license-gate.d.ts +12 -1
- package/dist/lib/module-license-gate.js +36 -15
- package/dist/lib/module-tier.d.ts +2 -8
- package/dist/lib/module-tier.js +13 -12
- package/dist/module-architecture.d.ts +20 -3
- package/dist/module-architecture.js +129 -552
- package/dist/paid-sections.d.ts +25 -1
- package/dist/paid-sections.js +88 -3
- package/dist/starter-grid.d.ts +6 -6
- package/dist/starter-grid.js +12 -5
- package/dist/surfaces.js +39 -2
- package/dist/template-record.d.ts +21 -3
- package/dist/template-record.js +38 -7
- package/dist/topology.d.ts +16 -3
- package/dist/topology.js +54 -23
- package/dist/update.d.ts +10 -1
- package/dist/update.js +715 -21
- package/package.json +3 -3
- package/release-scope.json +2 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
- package/site-templates/marketing-site/ailk-template.json +7 -1
- package/site-templates/marketing-site/provenance.json +7 -1
- package/site-templates/saas-product/ailk-template.json +2 -1
- package/site-templates/saas-product/provenance.json +1 -0
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/template-guard.sh +137 -0
- package/templates/.claude/rules/extending.md +106 -0
- package/templates/.claude/rules/loop-contract.md +2 -2
- package/templates/.claude/settings.json +11 -0
- package/templates/.env.example +20 -5
- package/templates/.github/workflows/ci.yml +7 -4
- package/templates/CLAUDE.md +11 -1
- package/templates/CONVENTIONS.md +8 -10
- package/templates/README.md +14 -13
- package/templates/apps/api/.env.example +23 -2
- package/templates/apps/api/CLAUDE.md +119 -83
- package/templates/apps/api/jest.config.cjs +15 -5
- package/templates/apps/api/package.json +16 -20
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +2 -2
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +4 -4
- package/templates/apps/api/src/index.ts +1 -1
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -3
- package/templates/apps/api/src/modules/commerce.ts +10 -0
- package/templates/apps/api/src/modules/content-write.ts +10 -0
- package/templates/apps/api/src/modules/multi-tenant.ts +10 -0
- package/templates/apps/api/src/modules/scheduling.ts +10 -0
- package/templates/apps/api/src/modules/signal-collectors.ts +10 -0
- package/templates/apps/api/src/modules/workspace.ts +10 -0
- package/templates/apps/api/src/server.ts +117 -1882
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/CLAUDE.md +90 -26
- package/templates/apps/mcp/__tests__/app-entry.test.ts +4 -254
- package/templates/apps/mcp/__tests__/boot-file.test.ts +92 -0
- package/templates/apps/mcp/jest.config.cjs +6 -1
- package/templates/apps/mcp/package.json +5 -13
- package/templates/apps/mcp/src/app.ts +8 -7
- package/templates/apps/mcp/src/index.ts +6 -34
- package/templates/apps/mcp/src/lib/paid-tools.generated.ts +10 -0
- package/templates/apps/mcp/src/lib/topology.generated.ts +2 -2
- package/templates/apps/mcp/src/site.ts +12 -0
- package/templates/apps/web/.env.example +1 -1
- package/templates/apps/web/CLAUDE.md +31 -3
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
- package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
- package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +1 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +22 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +7 -3
- package/templates/apps/web/app/[locale]/layout.tsx +33 -13
- package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
- package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +7 -3
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/__tests__/route.test.ts +222 -0
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +10 -3
- package/templates/apps/web/app/api/revalidate/route.ts +3 -3
- package/templates/apps/web/auth.ts +10 -6
- package/templates/apps/web/i18n/request.ts +49 -1
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
- package/templates/apps/web/lib/auth-email.ts +103 -21
- package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
- package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
- package/templates/apps/web/lib/relay.ts +2 -2
- package/templates/apps/web/lib/release-scope.ts +47 -0
- package/templates/apps/web/lib/server-api.ts +1 -1
- package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
- package/templates/apps/web/lib/site-contributions.ts +18 -0
- package/templates/apps/web/messages/en.json +186 -20
- package/templates/apps/web/messages/site/en.json +1 -0
- package/templates/apps/web/src/lib/commerce-claim.ts +1 -1
- package/templates/customizations.yaml +37 -0
- package/templates/database/prisma.config.ts +10 -14
- package/templates/database/seed-test.ts +2 -2
- package/templates/database/seed.ts +5 -5
- package/templates/package.json +12 -6
- package/templates/pnpm-workspace.yaml +0 -1
- package/templates/project.yaml +16 -8
- package/templates/tools/jest/reporters.cjs +65 -0
- package/templates/tools/lint-architecture.ts +553 -7
- package/templates/tsconfig.json +1 -2
- package/versions.json +49 -36
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +0 -409
- package/templates/apps/api/src/__tests__/factories/agent-caller.ts +0 -102
- package/templates/apps/api/src/__tests__/factories/content-fixture.ts +0 -138
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +0 -441
- package/templates/apps/api/src/__tests__/factories/index.ts +0 -70
- package/templates/apps/api/src/__tests__/factories/lead-fixture.ts +0 -82
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +0 -135
- package/templates/apps/api/src/__tests__/services/enrichment-shim.test.ts +0 -126
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +0 -132
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +0 -362
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +0 -184
- package/templates/apps/api/src/bin/abandon-sweep.ts +0 -42
- package/templates/apps/api/src/bin/rate-limit-probe.ts +0 -53
- package/templates/apps/api/src/bin/spokes.ts +0 -198
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +0 -45
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +0 -196
- package/templates/apps/api/src/config/__tests__/index.test.ts +0 -233
- package/templates/apps/api/src/config/__tests__/modules.test.ts +0 -76
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +0 -185
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +0 -238
- package/templates/apps/api/src/config/index.ts +0 -596
- package/templates/apps/api/src/config/modules.ts +0 -83
- package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +0 -581
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +0 -143
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +0 -237
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +0 -353
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +0 -201
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +0 -224
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +0 -120
- package/templates/apps/api/src/lib/__tests__/errors.test.ts +0 -94
- package/templates/apps/api/src/lib/__tests__/file-fallback.test.ts +0 -144
- package/templates/apps/api/src/lib/__tests__/fixtures/HomePage/home.mdx +0 -8
- package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +0 -149
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +0 -116
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +0 -209
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +0 -257
- package/templates/apps/api/src/lib/__tests__/lead-delivery.test.ts +0 -96
- package/templates/apps/api/src/lib/__tests__/outbound-http.test.ts +0 -125
- package/templates/apps/api/src/lib/__tests__/prisma.test.ts +0 -165
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +0 -317
- package/templates/apps/api/src/lib/__tests__/require-paid-tier.test.ts +0 -129
- package/templates/apps/api/src/lib/__tests__/site.test.ts +0 -308
- package/templates/apps/api/src/lib/__tests__/slice-load.test.ts +0 -89
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +0 -202
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +0 -217
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +0 -82
- package/templates/apps/api/src/lib/agent-store.ts +0 -613
- package/templates/apps/api/src/lib/auth-email-budget.ts +0 -161
- package/templates/apps/api/src/lib/consent-store.ts +0 -363
- package/templates/apps/api/src/lib/content-adapter.ts +0 -112
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +0 -96
- package/templates/apps/api/src/lib/error-serializer.ts +0 -164
- package/templates/apps/api/src/lib/errors.ts +0 -95
- package/templates/apps/api/src/lib/file-fallback.ts +0 -87
- package/templates/apps/api/src/lib/flow-completion-store.ts +0 -136
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +0 -455
- package/templates/apps/api/src/lib/lead-capture-store.ts +0 -122
- package/templates/apps/api/src/lib/lead-delivery.ts +0 -52
- package/templates/apps/api/src/lib/license-key-store.ts +0 -45
- package/templates/apps/api/src/lib/locales.ts +0 -201
- package/templates/apps/api/src/lib/outbound-http.ts +0 -127
- package/templates/apps/api/src/lib/prisma.ts +0 -21
- package/templates/apps/api/src/lib/proposal-paid-store.ts +0 -66
- package/templates/apps/api/src/lib/rate-limit-probe.ts +0 -165
- package/templates/apps/api/src/lib/rate-limit-store.ts +0 -142
- package/templates/apps/api/src/lib/require-commerce-claim.ts +0 -250
- package/templates/apps/api/src/lib/require-paid-tier.ts +0 -53
- package/templates/apps/api/src/lib/site.ts +0 -257
- package/templates/apps/api/src/lib/slice-load.ts +0 -90
- package/templates/apps/api/src/lib/spoke-store.ts +0 -180
- package/templates/apps/api/src/lib/stripe.ts +0 -321
- package/templates/apps/api/src/lib/webhook-idempotency.ts +0 -156
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +0 -111
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +0 -172
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +0 -119
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +0 -138
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +0 -142
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +0 -155
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +0 -139
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +0 -562
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +0 -76
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +0 -220
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +0 -97
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +0 -557
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +0 -72
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +0 -140
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +0 -721
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +0 -220
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +0 -430
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +0 -1409
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +0 -174
- package/templates/apps/api/src/middleware/auth.ts +0 -864
- package/templates/apps/api/src/middleware/default-tenant.ts +0 -166
- package/templates/apps/api/src/middleware/rate-limit.ts +0 -983
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +0 -391
- package/templates/apps/api/src/openapi/__tests__/spec-identity.test.ts +0 -182
- package/templates/apps/api/src/openapi/index.ts +0 -18
- package/templates/apps/api/src/openapi/spec.ts +0 -2148
- package/templates/apps/api/src/openapi/surface-drift.ts +0 -195
- package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +0 -175
- package/templates/apps/api/src/routes/aeo/index.ts +0 -107
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +0 -196
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +0 -249
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +0 -532
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +0 -313
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +0 -413
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +0 -609
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +0 -242
- package/templates/apps/api/src/routes/auth/tokens.ts +0 -211
- package/templates/apps/api/src/routes/auth/transactional-email.ts +0 -301
- package/templates/apps/api/src/routes/auth/user.ts +0 -305
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +0 -332
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +0 -178
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +0 -1426
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +0 -448
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +0 -1742
- package/templates/apps/api/src/routes/checkout/index.ts +0 -34
- package/templates/apps/api/src/routes/checkout/session.ts +0 -171
- package/templates/apps/api/src/routes/checkout/sessions.ts +0 -540
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +0 -386
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +0 -160
- package/templates/apps/api/src/routes/consent/account.ts +0 -268
- package/templates/apps/api/src/routes/consent/cookie.ts +0 -90
- package/templates/apps/api/src/routes/consent/index.ts +0 -26
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +0 -127
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +0 -853
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +0 -230
- package/templates/apps/api/src/routes/content/__tests__/list-types.test.ts +0 -44
- package/templates/apps/api/src/routes/content/__tests__/locale.test.ts +0 -118
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +0 -731
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +0 -753
- package/templates/apps/api/src/routes/content/__tests__/rate-limit.test.ts +0 -51
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +0 -450
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +0 -762
- package/templates/apps/api/src/routes/content/get-page.ts +0 -183
- package/templates/apps/api/src/routes/content/index.ts +0 -49
- package/templates/apps/api/src/routes/content/list-pages.ts +0 -74
- package/templates/apps/api/src/routes/content/list-types.ts +0 -19
- package/templates/apps/api/src/routes/content/offerings.ts +0 -184
- package/templates/apps/api/src/routes/content/pricing.ts +0 -176
- package/templates/apps/api/src/routes/content/rate-limit.ts +0 -22
- package/templates/apps/api/src/routes/content/search.ts +0 -100
- package/templates/apps/api/src/routes/content/shown-offerings.ts +0 -229
- package/templates/apps/api/src/routes/leads/__tests__/capture.test.ts +0 -88
- package/templates/apps/api/src/routes/leads/__tests__/list.test.ts +0 -171
- package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +0 -515
- package/templates/apps/api/src/routes/leads/__tests__/spoke.test.ts +0 -392
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +0 -165
- package/templates/apps/api/src/routes/leads/index.ts +0 -639
- package/templates/apps/api/src/routes/leads/list.ts +0 -104
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +0 -626
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +0 -1268
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +0 -847
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +0 -491
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +0 -476
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +0 -872
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +0 -1022
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +0 -484
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +0 -535
- package/templates/apps/api/src/routes/project-listings/index.ts +0 -164
- package/templates/apps/api/src/routes/project-listings/me.ts +0 -87
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +0 -224
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +0 -102
- package/templates/apps/api/src/routes/project-listings/public.ts +0 -98
- package/templates/apps/api/src/routes/project-listings/respond.ts +0 -48
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +0 -27
- package/templates/apps/api/src/routes/project-listings/start.ts +0 -140
- package/templates/apps/api/src/routes/project-listings/submit.ts +0 -130
- package/templates/apps/api/src/routes/project-listings/verify-email.ts +0 -150
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +0 -686
- package/templates/apps/api/src/routes/requests/confirm.ts +0 -601
- package/templates/apps/api/src/routes/requests/index.ts +0 -54
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +0 -306
- package/templates/apps/api/src/routes/test/auth/session.ts +0 -143
- package/templates/apps/api/src/routes/webhooks/README.md +0 -398
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +0 -649
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +0 -749
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +0 -449
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +0 -264
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +0 -159
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +0 -158
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +0 -814
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +0 -301
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +0 -369
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +0 -215
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +0 -301
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +0 -809
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +0 -1155
- package/templates/apps/api/src/routes/webhooks/index.ts +0 -141
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +0 -384
- package/templates/apps/api/src/routes/webhooks/stripe.ts +0 -2028
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +0 -645
- package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +0 -132
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +0 -1028
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +0 -313
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +0 -503
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +0 -65
- package/templates/apps/api/src/services/__tests__/consent.test.ts +0 -299
- package/templates/apps/api/src/services/__tests__/default-tenant.test.ts +0 -147
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +0 -877
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +0 -379
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +0 -368
- package/templates/apps/api/src/services/__tests__/entitlements.test.ts +0 -142
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +0 -854
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +0 -234
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +0 -251
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +0 -2660
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +0 -166
- package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +0 -389
- package/templates/apps/api/src/services/__tests__/person.test.ts +0 -1204
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +0 -450
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +0 -202
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +0 -151
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +0 -137
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +0 -531
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +0 -372
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +0 -348
- package/templates/apps/api/src/services/__tests__/spoke-resolution.test.ts +0 -660
- package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +0 -941
- package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +0 -588
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +0 -78
- package/templates/apps/api/src/services/abandon-email.ts +0 -336
- package/templates/apps/api/src/services/aeo-score.ts +0 -325
- package/templates/apps/api/src/services/answer-distributions.ts +0 -138
- package/templates/apps/api/src/services/audit-log.ts +0 -961
- package/templates/apps/api/src/services/checkout-buyer.ts +0 -168
- package/templates/apps/api/src/services/consent.ts +0 -834
- package/templates/apps/api/src/services/default-tenant.ts +0 -416
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +0 -750
- package/templates/apps/api/src/services/dunning-policy.ts +0 -294
- package/templates/apps/api/src/services/enrichment-shim.ts +0 -53
- package/templates/apps/api/src/services/entitlements.ts +0 -338
- package/templates/apps/api/src/services/hub-sign-in.ts +0 -472
- package/templates/apps/api/src/services/lead-accept-user.ts +0 -126
- package/templates/apps/api/src/services/lead-promotion.ts +0 -160
- package/templates/apps/api/src/services/lead-routing.ts +0 -1372
- package/templates/apps/api/src/services/leads-inbox.ts +0 -312
- package/templates/apps/api/src/services/listing-config.ts +0 -178
- package/templates/apps/api/src/services/magnet-completion.ts +0 -242
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +0 -348
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +0 -268
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +0 -975
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +0 -416
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +0 -400
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +0 -685
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +0 -231
- package/templates/apps/api/src/services/offerings/catalog.ts +0 -318
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +0 -403
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +0 -192
- package/templates/apps/api/src/services/offerings/connection.ts +0 -1083
- package/templates/apps/api/src/services/offerings/index.ts +0 -614
- package/templates/apps/api/src/services/offerings/source.ts +0 -140
- package/templates/apps/api/src/services/offerings/store.ts +0 -150
- package/templates/apps/api/src/services/owner-workspaces.ts +0 -132
- package/templates/apps/api/src/services/person.ts +0 -1211
- package/templates/apps/api/src/services/plan-offerings.ts +0 -125
- package/templates/apps/api/src/services/project-listing-resume-email.ts +0 -326
- package/templates/apps/api/src/services/project-listings.ts +0 -2085
- package/templates/apps/api/src/services/public-site-resolver.ts +0 -396
- package/templates/apps/api/src/services/request-confirm.ts +0 -698
- package/templates/apps/api/src/services/revalidation.ts +0 -235
- package/templates/apps/api/src/services/slack-notify.ts +0 -291
- package/templates/apps/api/src/services/usage-aggregation.ts +0 -516
- package/templates/apps/api/src/services/usage-credits.ts +0 -340
- package/templates/apps/api/src/services/verified-email.ts +0 -71
- package/templates/apps/mcp/__tests__/abuse/daily-cap.test.ts +0 -52
- package/templates/apps/mcp/__tests__/abuse/email-validator.test.ts +0 -46
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +0 -198
- package/templates/apps/mcp/__tests__/abuse/honeypot.test.ts +0 -36
- package/templates/apps/mcp/__tests__/abuse/user-agent-deny.test.ts +0 -54
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +0 -116
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +0 -299
- package/templates/apps/mcp/__tests__/boot.test.ts +0 -280
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +0 -359
- package/templates/apps/mcp/__tests__/config/modules.test.ts +0 -78
- package/templates/apps/mcp/__tests__/get_business_hours.test.ts +0 -82
- package/templates/apps/mcp/__tests__/get_faq.test.ts +0 -80
- package/templates/apps/mcp/__tests__/get_locations.test.ts +0 -65
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +0 -557
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +0 -524
- package/templates/apps/mcp/__tests__/get_site_info.test.ts +0 -81
- package/templates/apps/mcp/__tests__/http-transport.test.ts +0 -1232
- package/templates/apps/mcp/__tests__/index.test.ts +0 -195
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +0 -191
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +0 -531
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +0 -70
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -162
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +0 -93
- package/templates/apps/mcp/__tests__/lib/errors.test.ts +0 -75
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +0 -180
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +0 -124
- package/templates/apps/mcp/__tests__/list_pages.test.ts +0 -268
- package/templates/apps/mcp/__tests__/locale.test.ts +0 -120
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +0 -175
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +0 -101
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +0 -189
- package/templates/apps/mcp/__tests__/read_content.test.ts +0 -511
- package/templates/apps/mcp/__tests__/request_callback.test.ts +0 -160
- package/templates/apps/mcp/__tests__/search_content.test.ts +0 -271
- package/templates/apps/mcp/__tests__/server.test.ts +0 -388
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +0 -448
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +0 -222
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +0 -152
- package/templates/apps/mcp/__tests__/tools/create_project.test.ts +0 -243
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +0 -451
- package/templates/apps/mcp/src/abuse/daily-cap.ts +0 -47
- package/templates/apps/mcp/src/abuse/email-validator.ts +0 -25
- package/templates/apps/mcp/src/abuse/honeypot.ts +0 -21
- package/templates/apps/mcp/src/abuse/index.ts +0 -349
- package/templates/apps/mcp/src/abuse/user-agent-deny.ts +0 -58
- package/templates/apps/mcp/src/boot.ts +0 -167
- package/templates/apps/mcp/src/config/modules.ts +0 -43
- package/templates/apps/mcp/src/http-transport.ts +0 -564
- package/templates/apps/mcp/src/lib/audit-single-page.ts +0 -308
- package/templates/apps/mcp/src/lib/commerce-claim.ts +0 -263
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -116
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +0 -140
- package/templates/apps/mcp/src/lib/errors.ts +0 -61
- package/templates/apps/mcp/src/lib/license-key-store.ts +0 -45
- package/templates/apps/mcp/src/lib/locales.ts +0 -171
- package/templates/apps/mcp/src/lib/prisma.ts +0 -39
- package/templates/apps/mcp/src/lib/public-workspace.ts +0 -133
- package/templates/apps/mcp/src/lib/resolve-locale.ts +0 -30
- package/templates/apps/mcp/src/lib/shown-offerings.ts +0 -105
- package/templates/apps/mcp/src/lib/site-config.ts +0 -53
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +0 -59
- package/templates/apps/mcp/src/request-context.ts +0 -52
- package/templates/apps/mcp/src/server.ts +0 -343
- package/templates/apps/mcp/src/serverless-app.ts +0 -88
- package/templates/apps/mcp/src/tools/audit.ts +0 -127
- package/templates/apps/mcp/src/tools/capture_lead.ts +0 -66
- package/templates/apps/mcp/src/tools/create_project.ts +0 -134
- package/templates/apps/mcp/src/tools/get_business_hours.ts +0 -68
- package/templates/apps/mcp/src/tools/get_faq.ts +0 -71
- package/templates/apps/mcp/src/tools/get_locations.ts +0 -88
- package/templates/apps/mcp/src/tools/get_offerings.ts +0 -96
- package/templates/apps/mcp/src/tools/get_pricing.ts +0 -94
- package/templates/apps/mcp/src/tools/get_site_info.ts +0 -86
- package/templates/apps/mcp/src/tools/index.ts +0 -138
- package/templates/apps/mcp/src/tools/list_pages.ts +0 -68
- package/templates/apps/mcp/src/tools/person_notes.ts +0 -25
- package/templates/apps/mcp/src/tools/proposals.ts +0 -27
- package/templates/apps/mcp/src/tools/purchase.ts +0 -205
- package/templates/apps/mcp/src/tools/read_content.ts +0 -81
- package/templates/apps/mcp/src/tools/request_callback.ts +0 -52
- package/templates/apps/mcp/src/tools/search_content.ts +0 -63
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +0 -54
- package/templates/apps/mcp/src/workspace-server.ts +0 -224
- package/templates/database/CHANGELOG.md +0 -981
- package/templates/database/LICENSE +0 -202
- package/templates/database/NOTICE +0 -2
- package/templates/database/README.md +0 -243
- package/templates/database/__tests__/agents.db.test.ts +0 -320
- package/templates/database/__tests__/appointment.db.test.ts +0 -106
- package/templates/database/__tests__/artifact-content-store.db.test.ts +0 -316
- package/templates/database/__tests__/composite-fence.db.test.ts +0 -206
- package/templates/database/__tests__/consent-ledger.db.test.ts +0 -345
- package/templates/database/__tests__/consent-source-checkout-migration.test.ts +0 -79
- package/templates/database/__tests__/fence-fan-out.db.test.ts +0 -715
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +0 -79
- package/templates/database/__tests__/fixtures/person-backfill.json +0 -264
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +0 -182
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +0 -100
- package/templates/database/__tests__/flow-completion-migration.test.ts +0 -115
- package/templates/database/__tests__/flow-completion.db.test.ts +0 -136
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +0 -263
- package/templates/database/__tests__/flows-listing-diagnostic.test.ts +0 -392
- package/templates/database/__tests__/flows-presets.test.ts +0 -173
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +0 -161
- package/templates/database/__tests__/lead-idempotency-key.db.test.ts +0 -92
- package/templates/database/__tests__/member-area.db.test.ts +0 -257
- package/templates/database/__tests__/member-contract-migration.test.ts +0 -85
- package/templates/database/__tests__/member-contract.db.test.ts +0 -413
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +0 -270
- package/templates/database/__tests__/offerings-migration.test.ts +0 -197
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +0 -86
- package/templates/database/__tests__/offerings.db.test.ts +0 -303
- package/templates/database/__tests__/outbound-message-action.db.test.ts +0 -80
- package/templates/database/__tests__/person-contact.db.test.ts +0 -563
- package/templates/database/__tests__/person-notes.db.test.ts +0 -249
- package/templates/database/__tests__/person.db.test.ts +0 -586
- package/templates/database/__tests__/piece-kind-migration.test.ts +0 -67
- package/templates/database/__tests__/piece-kind.db.test.ts +0 -208
- package/templates/database/__tests__/prefixed-ids.db.test.ts +0 -222
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +0 -111
- package/templates/database/__tests__/seed.test.ts +0 -272
- package/templates/database/__tests__/spoke-registry.db.test.ts +0 -191
- package/templates/database/__tests__/tenancy.test.ts +0 -141
- package/templates/database/__tests__/trial-magnet-migration.test.ts +0 -186
- package/templates/database/__tests__/user-cascade.test.ts +0 -140
- package/templates/database/_gitignore +0 -8
- package/templates/database/auth/.gitkeep +0 -0
- package/templates/database/auth/schema.prisma +0 -641
- package/templates/database/config/.gitkeep +0 -0
- package/templates/database/config/schema.prisma +0 -441
- package/templates/database/content/.gitkeep +0 -0
- package/templates/database/content/schema.prisma +0 -339
- package/templates/database/eslint.config.mjs +0 -69
- package/templates/database/flows/listing-diagnostic.ts +0 -182
- package/templates/database/flows/presets.ts +0 -315
- package/templates/database/inbox/.gitkeep +0 -0
- package/templates/database/inbox/README.md +0 -3
- package/templates/database/inbox/__tests__/schema.test.ts +0 -44
- package/templates/database/inbox/schema.prisma +0 -1447
- package/templates/database/jest.config.cjs +0 -36
- package/templates/database/migrations/20260508000000_add_inbox_namespace/migration.sql +0 -328
- package/templates/database/migrations/20260513000000_better_auth_migration/migration.sql +0 -100
- package/templates/database/migrations/20260515000000_add_user_billing_fields/migration.sql +0 -28
- package/templates/database/migrations/20260516000000_add_user_tier_care_plan_seat/migration.sql +0 -25
- package/templates/database/migrations/20260518000000_add_telemetry_records/migration.sql +0 -42
- package/templates/database/migrations/20260525000000_theme_wiring/migration.sql +0 -67
- package/templates/database/migrations/20260710000000_retire_commercial_tier/migration.sql +0 -21
- package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +0 -201
- package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +0 -67
- package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +0 -46
- package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +0 -25
- package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +0 -49
- package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +0 -197
- package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +0 -32
- package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +0 -22
- package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +0 -151
- package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +0 -223
- package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +0 -22
- package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +0 -32
- package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +0 -40
- package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +0 -4
- package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +0 -2
- package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +0 -2
- package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +0 -29
- package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +0 -6
- package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +0 -39
- package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +0 -24
- package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +0 -3
- package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +0 -33
- package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +0 -30
- package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +0 -47
- package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +0 -16
- package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +0 -47
- package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +0 -10
- package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +0 -26
- package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +0 -90
- package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +0 -174
- package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +0 -14
- package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +0 -25
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +0 -32
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +0 -71
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +0 -30
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +0 -49
- package/templates/database/migrations/20260914120000_listing_founder_identity/migration.sql +0 -123
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +0 -15
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +0 -158
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +0 -40
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +0 -67
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +0 -70
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +0 -181
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +0 -172
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +0 -41
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +0 -185
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +0 -70
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +0 -57
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +0 -68
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +0 -1067
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +0 -210
- package/templates/database/migrations/20261003120000_spoke_registry/migration.sql +0 -80
- package/templates/database/migrations/20261003130000_scheduling_connection/migration.sql +0 -49
- package/templates/database/migrations/20261005120000_agents/migration.sql +0 -84
- package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +0 -35
- package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +0 -20
- package/templates/database/migrations/20261009120000_agent_grants/migration.sql +0 -33
- package/templates/database/migrations/20261010120000_person/migration.sql +0 -106
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +0 -132
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +0 -20
- package/templates/database/migrations/20261011120000_member_area/migration.sql +0 -158
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +0 -21
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +0 -192
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +0 -20
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +0 -122
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +0 -231
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +0 -77
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +0 -43
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +0 -95
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +0 -30
- package/templates/database/migrations/20261018120000_flow_completion/migration.sql +0 -52
- package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +0 -27
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +0 -146
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +0 -77
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +0 -45
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +0 -47
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +0 -16
- package/templates/database/migrations/20261025120000_trial_magnet/migration.sql +0 -28
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +0 -23
- package/templates/database/migrations/migration_lock.toml +0 -3
- package/templates/database/ops/.gitkeep +0 -0
- package/templates/database/ops/schema.prisma +0 -43
- package/templates/database/package.json +0 -89
- package/templates/database/schema.prisma +0 -71
- package/templates/database/scripts/db-generate-locked.sh +0 -80
- package/templates/database/scripts/person-backfill.sql +0 -132
- package/templates/database/tenancy.ts +0 -183
- package/templates/database/tsconfig.build.json +0 -16
- package/templates/database/tsconfig.flows.json +0 -15
- package/templates/database/tsconfig.json +0 -16
- package/templates/database/tsconfig.tenancy.json +0 -14
|
@@ -1,1742 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file sessions.test.ts
|
|
3
|
-
* Integration tests for POST /api/checkout/sessions
|
|
4
|
-
*
|
|
5
|
-
* Tests use app.inject() and mock @working-theory/stripe-client so no real Stripe
|
|
6
|
-
* calls are made. The mock is set up before the module under test is loaded
|
|
7
|
-
* to ensure the mock is in place when the handler resolves its imports.
|
|
8
|
-
*
|
|
9
|
-
* Criteria tested (spec §5.1):
|
|
10
|
-
* 1. Happy path: 201 { sessionId, url } with correct productCode forwarded
|
|
11
|
-
* 2. Validation — missing productCode: 400 validation-error
|
|
12
|
-
* 3. Validation — malformed productCode (fails regex): 400 validation-error
|
|
13
|
-
* 4. Validation — quantity ≤0: 400 validation-error
|
|
14
|
-
* 5. Validation — quantity >999: 400 validation-error
|
|
15
|
-
* 6. Validation — unparseable successUrl: 400 validation-error
|
|
16
|
-
* 7. Validation — javascript: scheme successUrl: 400 validation-error
|
|
17
|
-
* 8. Validation — empty host successUrl: 400 validation-error
|
|
18
|
-
* 9. Unknown product code (UnknownProductCodeError): 404 unknown-product-code
|
|
19
|
-
* 10. Product code NOT echoed in 404 response body
|
|
20
|
-
* 11. Stripe outage (generic error): 502 checkout-unavailable
|
|
21
|
-
* 12. Stripe outage detail NOT in response body
|
|
22
|
-
* 13. Live-key guard: live-key prefix → 500 live-mode-forbidden, Stripe NOT called
|
|
23
|
-
* 14. Defaults quantity to 1 when omitted
|
|
24
|
-
* 15. PII guard: log lines contain session-id prefix only (first 8 chars), not full URL
|
|
25
|
-
* 16. StripeConfigError (missing key): 500 config-error
|
|
26
|
-
* 17. StripeConfigError message NOT in response body
|
|
27
|
-
* 18. allowPromotionCodes forwarded when supplied, omitted when absent (#4398)
|
|
28
|
-
* 19. Validation — non-boolean allowPromotionCodes: 400 validation-error (#4398)
|
|
29
|
-
*/
|
|
30
|
-
|
|
31
|
-
import type { FastifyInstance } from "fastify";
|
|
32
|
-
|
|
33
|
-
// ── Mocks (must precede any imports of the module under test) ────────────────
|
|
34
|
-
|
|
35
|
-
const mockCreateCheckoutSession = jest.fn();
|
|
36
|
-
const mockAssertSandboxKey = jest.fn();
|
|
37
|
-
|
|
38
|
-
jest.mock("@working-theory/stripe-client", () => ({
|
|
39
|
-
createCheckoutSession: (...args: unknown[]) =>
|
|
40
|
-
mockCreateCheckoutSession(...args),
|
|
41
|
-
assertSandboxKey: (...args: unknown[]) => mockAssertSandboxKey(...args),
|
|
42
|
-
// #6084 — `../index` now also loads `./session.js` (the GET), which
|
|
43
|
-
// imports these two through the same `lib/stripe.js` chokepoint. Neither
|
|
44
|
-
// is exercised by this suite (POST-only), but they must resolve to real
|
|
45
|
-
// values rather than `undefined` for the module graph to load cleanly.
|
|
46
|
-
retrieveCheckoutSession: jest.fn(),
|
|
47
|
-
UnknownSessionError: class UnknownSessionError extends Error {
|
|
48
|
-
constructor(sessionId: string) {
|
|
49
|
-
super(`No Checkout Session found for id: ${sessionId}.`);
|
|
50
|
-
this.name = "UnknownSessionError";
|
|
51
|
-
}
|
|
52
|
-
},
|
|
53
|
-
UnknownProductCodeError: class UnknownProductCodeError extends Error {
|
|
54
|
-
constructor(productCode: string) {
|
|
55
|
-
super(`No active price found for lookup_key: ${productCode}`);
|
|
56
|
-
this.name = "UnknownProductCodeError";
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
StripeConfigError: class StripeConfigError extends Error {
|
|
60
|
-
constructor(message: string) {
|
|
61
|
-
super(message);
|
|
62
|
-
this.name = "StripeConfigError";
|
|
63
|
-
}
|
|
64
|
-
},
|
|
65
|
-
UnknownCustomerError: class UnknownCustomerError extends Error {
|
|
66
|
-
constructor() {
|
|
67
|
-
super("The Checkout Session's pinned Stripe customer does not exist.");
|
|
68
|
-
this.name = "UnknownCustomerError";
|
|
69
|
-
}
|
|
70
|
-
},
|
|
71
|
-
}));
|
|
72
|
-
|
|
73
|
-
// #6866 — the route's optional cookie identity reads the Session and User
|
|
74
|
-
// rows (the auth.cookie-path.test.ts pattern), so the real resolver and
|
|
75
|
-
// buyer service run against this mock.
|
|
76
|
-
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted; the DB boundary, mirrors auth.cookie-path.test.ts
|
|
77
|
-
jest.mock("../../../lib/prisma.js", () => ({
|
|
78
|
-
prisma: {
|
|
79
|
-
session: { findUnique: jest.fn() },
|
|
80
|
-
user: { findUnique: jest.fn() },
|
|
81
|
-
apiToken: { findUnique: jest.fn(), update: jest.fn() },
|
|
82
|
-
},
|
|
83
|
-
}));
|
|
84
|
-
|
|
85
|
-
// #8931 — the route now asks the Offerings service which path a request
|
|
86
|
-
// takes before the env checks; every case in this suite is an env-path case,
|
|
87
|
-
// so the service answers `env` for both the POST and the GET.
|
|
88
|
-
// eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
|
|
89
|
-
jest.mock("../../../services/offerings/checkout-authorization.js", () => ({
|
|
90
|
-
resolveOfferingsCheckoutPath: jest.fn(async () => ({ kind: "env" })),
|
|
91
|
-
readOfferingsCheckoutSession: jest.fn(async () => ({ kind: "env" })),
|
|
92
|
-
startOfferingsCheckout: jest.fn(),
|
|
93
|
-
OFFERINGS_CHECKOUT_PER_WORKSPACE_PER_MINUTE: 60,
|
|
94
|
-
}));
|
|
95
|
-
|
|
96
|
-
// Import after mock setup
|
|
97
|
-
import { config } from "../../../config/index.js";
|
|
98
|
-
import { UnknownCustomerError } from "../../../lib/stripe.js";
|
|
99
|
-
import { prisma } from "../../../lib/prisma.js";
|
|
100
|
-
import { isOfferingsProduct } from "../../../services/offerings/catalog.js";
|
|
101
|
-
import { checkoutRoutes } from "../index";
|
|
102
|
-
|
|
103
|
-
// #6877 — pin the suite's site origin to the fixtures' own origin, so every
|
|
104
|
-
// anonymous case below sends same-origin redirects. Runs before any
|
|
105
|
-
// `describe` is collected, so the #6866 block's SAME_ORIGIN reads it too.
|
|
106
|
-
// #7395 — the redirect lock and the buyer's cookie check read the trusted
|
|
107
|
-
// allowlist, not FRONTEND_URL, so the suite pins both.
|
|
108
|
-
config.FRONTEND_URL = "https://example.com";
|
|
109
|
-
config.TRUSTED_ORIGINS = ["https://example.com"];
|
|
110
|
-
|
|
111
|
-
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
function buildApp(): FastifyInstance {
|
|
114
|
-
const fastify = require("fastify")({ logger: false });
|
|
115
|
-
fastify.register(checkoutRoutes);
|
|
116
|
-
return fastify;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const VALID_BODY = {
|
|
120
|
-
productCode: "ailk-pro",
|
|
121
|
-
successUrl: "https://example.com/success",
|
|
122
|
-
cancelUrl: "https://example.com/cancel",
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
// ── Test suite ───────────────────────────────────────────────────────────────
|
|
126
|
-
|
|
127
|
-
describe("POST /api/checkout/sessions", () => {
|
|
128
|
-
let app: FastifyInstance;
|
|
129
|
-
|
|
130
|
-
beforeEach(async () => {
|
|
131
|
-
jest.clearAllMocks();
|
|
132
|
-
// Default: a present sandbox key, so the new (#3757) emptiness check at
|
|
133
|
-
// the call site doesn't short-circuit tests that aren't exercising it.
|
|
134
|
-
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
135
|
-
// Default: sandbox key guard passes
|
|
136
|
-
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
137
|
-
// Default: successful checkout session
|
|
138
|
-
mockCreateCheckoutSession.mockResolvedValue({
|
|
139
|
-
sessionId: "cs_test_abc12345xyz",
|
|
140
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_abc12345xyz",
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
app = buildApp();
|
|
144
|
-
await app.ready();
|
|
145
|
-
});
|
|
146
|
-
|
|
147
|
-
afterEach(async () => {
|
|
148
|
-
await app.close();
|
|
149
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// ── Happy path ─────────────────────────────────────────────────────────────
|
|
153
|
-
|
|
154
|
-
it("returns 201 with sessionId and url for a valid request", async () => {
|
|
155
|
-
const res = await app.inject({
|
|
156
|
-
method: "POST",
|
|
157
|
-
url: "/v1/checkout/sessions",
|
|
158
|
-
payload: VALID_BODY,
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
expect(res.statusCode).toBe(201);
|
|
162
|
-
const body = res.json();
|
|
163
|
-
expect(body).toHaveProperty("sessionId");
|
|
164
|
-
expect(body).toHaveProperty("url");
|
|
165
|
-
expect(body.sessionId).toBe("cs_test_abc12345xyz");
|
|
166
|
-
expect(body.url).toBe(
|
|
167
|
-
"https://checkout.stripe.com/c/pay/cs_test_abc12345xyz",
|
|
168
|
-
);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it("calls createCheckoutSession with the exact productCode from the request", async () => {
|
|
172
|
-
await app.inject({
|
|
173
|
-
method: "POST",
|
|
174
|
-
url: "/v1/checkout/sessions",
|
|
175
|
-
payload: { ...VALID_BODY, productCode: "my-exact-product" },
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
179
|
-
const callArg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
180
|
-
expect(callArg.productCode).toBe("my-exact-product");
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
// ── allowPromotionCodes passthrough (#4398) ────────────────────────────────
|
|
184
|
-
|
|
185
|
-
it("forwards allowPromotionCodes to createCheckoutSession when supplied", async () => {
|
|
186
|
-
await app.inject({
|
|
187
|
-
method: "POST",
|
|
188
|
-
url: "/v1/checkout/sessions",
|
|
189
|
-
payload: { ...VALID_BODY, allowPromotionCodes: true },
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
193
|
-
expect.objectContaining({ allowPromotionCodes: true }),
|
|
194
|
-
{ refuseProduct: isOfferingsProduct },
|
|
195
|
-
);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it("#8931 A9: an anonymous env checkout passes the Offerings marker as refuseProduct", async () => {
|
|
199
|
-
await app.inject({
|
|
200
|
-
method: "POST",
|
|
201
|
-
url: "/v1/checkout/sessions",
|
|
202
|
-
payload: VALID_BODY,
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
206
|
-
const [, options] = mockCreateCheckoutSession.mock.calls[0];
|
|
207
|
-
expect(Object.keys(options)).toEqual(["refuseProduct"]);
|
|
208
|
-
expect(options.refuseProduct).toBe(isOfferingsProduct);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("omits allowPromotionCodes from the client call when not supplied", async () => {
|
|
212
|
-
await app.inject({
|
|
213
|
-
method: "POST",
|
|
214
|
-
url: "/v1/checkout/sessions",
|
|
215
|
-
payload: VALID_BODY,
|
|
216
|
-
});
|
|
217
|
-
|
|
218
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0]).not.toHaveProperty(
|
|
219
|
-
"allowPromotionCodes",
|
|
220
|
-
);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
it("returns 400 validation-error for a non-boolean allowPromotionCodes", async () => {
|
|
224
|
-
const res = await app.inject({
|
|
225
|
-
method: "POST",
|
|
226
|
-
url: "/v1/checkout/sessions",
|
|
227
|
-
payload: { ...VALID_BODY, allowPromotionCodes: "yes" },
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
expect(res.statusCode).toBe(400);
|
|
231
|
-
expect(res.json().error).toBe("validation-error");
|
|
232
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
it("defaults quantity to 1 when omitted", async () => {
|
|
236
|
-
await app.inject({
|
|
237
|
-
method: "POST",
|
|
238
|
-
url: "/v1/checkout/sessions",
|
|
239
|
-
payload: VALID_BODY,
|
|
240
|
-
});
|
|
241
|
-
|
|
242
|
-
const callArg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
243
|
-
expect(callArg.quantity).toBe(1);
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
it("passes explicit quantity through to wrapper", async () => {
|
|
247
|
-
await app.inject({
|
|
248
|
-
method: "POST",
|
|
249
|
-
url: "/v1/checkout/sessions",
|
|
250
|
-
payload: { ...VALID_BODY, quantity: 3 },
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
const callArg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
254
|
-
expect(callArg.quantity).toBe(3);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// ── Validation — productCode ───────────────────────────────────────────────
|
|
258
|
-
|
|
259
|
-
it("returns 400 when productCode is missing", async () => {
|
|
260
|
-
const { productCode: _, ...body } = VALID_BODY;
|
|
261
|
-
const res = await app.inject({
|
|
262
|
-
method: "POST",
|
|
263
|
-
url: "/v1/checkout/sessions",
|
|
264
|
-
payload: body,
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
expect(res.statusCode).toBe(400);
|
|
268
|
-
const resBody = res.json();
|
|
269
|
-
expect(resBody.error).toBe("validation-error");
|
|
270
|
-
expect(resBody.details).toBeDefined();
|
|
271
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
it("returns 400 when productCode contains invalid characters (space)", async () => {
|
|
275
|
-
const res = await app.inject({
|
|
276
|
-
method: "POST",
|
|
277
|
-
url: "/v1/checkout/sessions",
|
|
278
|
-
payload: { ...VALID_BODY, productCode: "invalid product code" },
|
|
279
|
-
});
|
|
280
|
-
|
|
281
|
-
expect(res.statusCode).toBe(400);
|
|
282
|
-
const resBody = res.json();
|
|
283
|
-
expect(resBody.error).toBe("validation-error");
|
|
284
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
285
|
-
});
|
|
286
|
-
|
|
287
|
-
it("returns 400 when productCode is empty string", async () => {
|
|
288
|
-
const res = await app.inject({
|
|
289
|
-
method: "POST",
|
|
290
|
-
url: "/v1/checkout/sessions",
|
|
291
|
-
payload: { ...VALID_BODY, productCode: "" },
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
expect(res.statusCode).toBe(400);
|
|
295
|
-
expect(res.json().error).toBe("validation-error");
|
|
296
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it("returns 400 when productCode exceeds 200 characters", async () => {
|
|
300
|
-
const res = await app.inject({
|
|
301
|
-
method: "POST",
|
|
302
|
-
url: "/v1/checkout/sessions",
|
|
303
|
-
payload: { ...VALID_BODY, productCode: "a".repeat(201) },
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
expect(res.statusCode).toBe(400);
|
|
307
|
-
expect(res.json().error).toBe("validation-error");
|
|
308
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
309
|
-
});
|
|
310
|
-
|
|
311
|
-
it("accepts productCode with allowed special characters (. _ -)", async () => {
|
|
312
|
-
const res = await app.inject({
|
|
313
|
-
method: "POST",
|
|
314
|
-
url: "/v1/checkout/sessions",
|
|
315
|
-
payload: { ...VALID_BODY, productCode: "ailk.pro_v2-basic" },
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
expect(res.statusCode).toBe(201);
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
// ── Validation — quantity ──────────────────────────────────────────────────
|
|
322
|
-
|
|
323
|
-
it("returns 400 when quantity is 0", async () => {
|
|
324
|
-
const res = await app.inject({
|
|
325
|
-
method: "POST",
|
|
326
|
-
url: "/v1/checkout/sessions",
|
|
327
|
-
payload: { ...VALID_BODY, quantity: 0 },
|
|
328
|
-
});
|
|
329
|
-
|
|
330
|
-
expect(res.statusCode).toBe(400);
|
|
331
|
-
expect(res.json().error).toBe("validation-error");
|
|
332
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
it("returns 400 when quantity is negative", async () => {
|
|
336
|
-
const res = await app.inject({
|
|
337
|
-
method: "POST",
|
|
338
|
-
url: "/v1/checkout/sessions",
|
|
339
|
-
payload: { ...VALID_BODY, quantity: -1 },
|
|
340
|
-
});
|
|
341
|
-
|
|
342
|
-
expect(res.statusCode).toBe(400);
|
|
343
|
-
expect(res.json().error).toBe("validation-error");
|
|
344
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it("returns 400 when quantity is 1000", async () => {
|
|
348
|
-
const res = await app.inject({
|
|
349
|
-
method: "POST",
|
|
350
|
-
url: "/v1/checkout/sessions",
|
|
351
|
-
payload: { ...VALID_BODY, quantity: 1000 },
|
|
352
|
-
});
|
|
353
|
-
|
|
354
|
-
expect(res.statusCode).toBe(400);
|
|
355
|
-
expect(res.json().error).toBe("validation-error");
|
|
356
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
it("accepts quantity of 999 (max allowed)", async () => {
|
|
360
|
-
const res = await app.inject({
|
|
361
|
-
method: "POST",
|
|
362
|
-
url: "/v1/checkout/sessions",
|
|
363
|
-
payload: { ...VALID_BODY, quantity: 999 },
|
|
364
|
-
});
|
|
365
|
-
|
|
366
|
-
expect(res.statusCode).toBe(201);
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
// ── Validation — URL fields ────────────────────────────────────────────────
|
|
370
|
-
|
|
371
|
-
it("returns 400 when successUrl is unparseable", async () => {
|
|
372
|
-
const res = await app.inject({
|
|
373
|
-
method: "POST",
|
|
374
|
-
url: "/v1/checkout/sessions",
|
|
375
|
-
payload: { ...VALID_BODY, successUrl: "not-a-url" },
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
expect(res.statusCode).toBe(400);
|
|
379
|
-
expect(res.json().error).toBe("validation-error");
|
|
380
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it("returns 400 when successUrl uses javascript: scheme", async () => {
|
|
384
|
-
const res = await app.inject({
|
|
385
|
-
method: "POST",
|
|
386
|
-
url: "/v1/checkout/sessions",
|
|
387
|
-
payload: {
|
|
388
|
-
...VALID_BODY,
|
|
389
|
-
successUrl: "javascript:alert(1)",
|
|
390
|
-
},
|
|
391
|
-
});
|
|
392
|
-
|
|
393
|
-
expect(res.statusCode).toBe(400);
|
|
394
|
-
expect(res.json().error).toBe("validation-error");
|
|
395
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
it("returns 400 when cancelUrl has an empty host (data: URI)", async () => {
|
|
399
|
-
// data: URIs parse successfully but have an empty host — they are not
|
|
400
|
-
// valid redirect targets and must be rejected.
|
|
401
|
-
const res = await app.inject({
|
|
402
|
-
method: "POST",
|
|
403
|
-
url: "/v1/checkout/sessions",
|
|
404
|
-
payload: { ...VALID_BODY, cancelUrl: "data:text/html,<h1>xss</h1>" },
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
expect(res.statusCode).toBe(400);
|
|
408
|
-
expect(res.json().error).toBe("validation-error");
|
|
409
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
it("returns 400 when successUrl uses ftp: scheme", async () => {
|
|
413
|
-
// ftp:// parses with a non-empty host but is not a valid http(s) redirect —
|
|
414
|
-
// the allowlist (http:/https: only) must block it.
|
|
415
|
-
const res = await app.inject({
|
|
416
|
-
method: "POST",
|
|
417
|
-
url: "/v1/checkout/sessions",
|
|
418
|
-
payload: { ...VALID_BODY, successUrl: "ftp://attacker.com/path" },
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
expect(res.statusCode).toBe(400);
|
|
422
|
-
expect(res.json().error).toBe("validation-error");
|
|
423
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
424
|
-
});
|
|
425
|
-
|
|
426
|
-
it("returns 400 when cancelUrl uses ftp: scheme", async () => {
|
|
427
|
-
const res = await app.inject({
|
|
428
|
-
method: "POST",
|
|
429
|
-
url: "/v1/checkout/sessions",
|
|
430
|
-
payload: { ...VALID_BODY, cancelUrl: "ftp://attacker.com/path" },
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
expect(res.statusCode).toBe(400);
|
|
434
|
-
expect(res.json().error).toBe("validation-error");
|
|
435
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
it("returns 400 when successUrl is missing", async () => {
|
|
439
|
-
const { successUrl: _, ...body } = VALID_BODY;
|
|
440
|
-
const res = await app.inject({
|
|
441
|
-
method: "POST",
|
|
442
|
-
url: "/v1/checkout/sessions",
|
|
443
|
-
payload: body,
|
|
444
|
-
});
|
|
445
|
-
|
|
446
|
-
expect(res.statusCode).toBe(400);
|
|
447
|
-
expect(res.json().error).toBe("validation-error");
|
|
448
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
// ── 404 — unknown product code ─────────────────────────────────────────────
|
|
452
|
-
|
|
453
|
-
it("returns 404 unknown-product-code when wrapper throws UnknownProductCodeError", async () => {
|
|
454
|
-
const { UnknownProductCodeError } = jest.requireMock(
|
|
455
|
-
"@working-theory/stripe-client",
|
|
456
|
-
);
|
|
457
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
458
|
-
new UnknownProductCodeError("no-such-product"),
|
|
459
|
-
);
|
|
460
|
-
|
|
461
|
-
const res = await app.inject({
|
|
462
|
-
method: "POST",
|
|
463
|
-
url: "/v1/checkout/sessions",
|
|
464
|
-
payload: { ...VALID_BODY, productCode: "no-such-product" },
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
expect(res.statusCode).toBe(404);
|
|
468
|
-
expect(res.json().error).toBe("unknown-product-code");
|
|
469
|
-
});
|
|
470
|
-
|
|
471
|
-
it("does NOT echo the product code in the 404 response body", async () => {
|
|
472
|
-
const { UnknownProductCodeError } = jest.requireMock(
|
|
473
|
-
"@working-theory/stripe-client",
|
|
474
|
-
);
|
|
475
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
476
|
-
new UnknownProductCodeError("secret-product-code"),
|
|
477
|
-
);
|
|
478
|
-
|
|
479
|
-
const res = await app.inject({
|
|
480
|
-
method: "POST",
|
|
481
|
-
url: "/v1/checkout/sessions",
|
|
482
|
-
payload: { ...VALID_BODY, productCode: "secret-product-code" },
|
|
483
|
-
});
|
|
484
|
-
|
|
485
|
-
expect(res.statusCode).toBe(404);
|
|
486
|
-
const resBody = JSON.stringify(res.json());
|
|
487
|
-
expect(resBody).not.toContain("secret-product-code");
|
|
488
|
-
});
|
|
489
|
-
|
|
490
|
-
// ── 502 — Stripe outage ────────────────────────────────────────────────────
|
|
491
|
-
|
|
492
|
-
it("returns 502 checkout-unavailable when wrapper throws a generic error", async () => {
|
|
493
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
494
|
-
new Error("Stripe connection timeout"),
|
|
495
|
-
);
|
|
496
|
-
|
|
497
|
-
const res = await app.inject({
|
|
498
|
-
method: "POST",
|
|
499
|
-
url: "/v1/checkout/sessions",
|
|
500
|
-
payload: VALID_BODY,
|
|
501
|
-
});
|
|
502
|
-
|
|
503
|
-
expect(res.statusCode).toBe(502);
|
|
504
|
-
expect(res.json().error).toBe("checkout-unavailable");
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
it("does NOT include Stripe error detail in the 502 response body", async () => {
|
|
508
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
509
|
-
new Error("Internal Stripe secret details"),
|
|
510
|
-
);
|
|
511
|
-
|
|
512
|
-
const res = await app.inject({
|
|
513
|
-
method: "POST",
|
|
514
|
-
url: "/v1/checkout/sessions",
|
|
515
|
-
payload: VALID_BODY,
|
|
516
|
-
});
|
|
517
|
-
|
|
518
|
-
expect(res.statusCode).toBe(502);
|
|
519
|
-
const resBody = JSON.stringify(res.json());
|
|
520
|
-
expect(resBody).not.toContain("Internal Stripe secret details");
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
// ── 500 — config error (missing key) ─────────────────────────────────────
|
|
524
|
-
|
|
525
|
-
it("returns 500 config-error when wrapper throws StripeConfigError", async () => {
|
|
526
|
-
const { StripeConfigError } = jest.requireMock(
|
|
527
|
-
"@working-theory/stripe-client",
|
|
528
|
-
);
|
|
529
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
530
|
-
new StripeConfigError("STRIPE_SECRET_KEY is missing"),
|
|
531
|
-
);
|
|
532
|
-
|
|
533
|
-
const res = await app.inject({
|
|
534
|
-
method: "POST",
|
|
535
|
-
url: "/v1/checkout/sessions",
|
|
536
|
-
payload: VALID_BODY,
|
|
537
|
-
});
|
|
538
|
-
|
|
539
|
-
expect(res.statusCode).toBe(500);
|
|
540
|
-
expect(res.json().error).toBe("config-error");
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
it("does NOT include StripeConfigError message in the 500 config-error response body", async () => {
|
|
544
|
-
const { StripeConfigError } = jest.requireMock(
|
|
545
|
-
"@working-theory/stripe-client",
|
|
546
|
-
);
|
|
547
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
548
|
-
new StripeConfigError("STRIPE_SECRET_KEY is missing"),
|
|
549
|
-
);
|
|
550
|
-
|
|
551
|
-
const res = await app.inject({
|
|
552
|
-
method: "POST",
|
|
553
|
-
url: "/v1/checkout/sessions",
|
|
554
|
-
payload: VALID_BODY,
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
expect(res.statusCode).toBe(500);
|
|
558
|
-
const resBody = JSON.stringify(res.json());
|
|
559
|
-
expect(resBody).not.toContain("STRIPE_SECRET_KEY is missing");
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
// ── 500 — live-key guard ───────────────────────────────────────────────────
|
|
563
|
-
|
|
564
|
-
it("returns 500 live-mode-forbidden when STRIPE_SECRET_KEY is a live key", async () => {
|
|
565
|
-
// Assemble the forbidden prefix at runtime from base64 to avoid writing the
|
|
566
|
-
// literal substring in source (architecture lint blocks it in all files).
|
|
567
|
-
// "c2tfbGl2ZV8=" decodes to the live-key prefix.
|
|
568
|
-
const livePrefix = Buffer.from("c2tfbGl2ZV8=", "base64").toString("utf8");
|
|
569
|
-
process.env["STRIPE_SECRET_KEY"] = livePrefix + "xxxFakeKeyForTest";
|
|
570
|
-
mockAssertSandboxKey.mockImplementation(() => {
|
|
571
|
-
throw new Error("live key detected");
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
const res = await app.inject({
|
|
575
|
-
method: "POST",
|
|
576
|
-
url: "/v1/checkout/sessions",
|
|
577
|
-
payload: VALID_BODY,
|
|
578
|
-
});
|
|
579
|
-
|
|
580
|
-
expect(res.statusCode).toBe(500);
|
|
581
|
-
expect(res.json().error).toBe("live-mode-forbidden");
|
|
582
|
-
// Stripe was NOT called
|
|
583
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
584
|
-
});
|
|
585
|
-
|
|
586
|
-
it("returns 500 live-mode-forbidden when STRIPE_SECRET_KEY is a restricted live key", async () => {
|
|
587
|
-
// "cmtfbGl2ZV8=" decodes to the restricted-live-key prefix.
|
|
588
|
-
const restrictedLivePrefix = Buffer.from("cmtfbGl2ZV8=", "base64").toString(
|
|
589
|
-
"utf8",
|
|
590
|
-
);
|
|
591
|
-
process.env["STRIPE_SECRET_KEY"] =
|
|
592
|
-
restrictedLivePrefix + "xxxFakeKeyForTest";
|
|
593
|
-
mockAssertSandboxKey.mockImplementation(() => {
|
|
594
|
-
throw new Error("live key detected");
|
|
595
|
-
});
|
|
596
|
-
|
|
597
|
-
const res = await app.inject({
|
|
598
|
-
method: "POST",
|
|
599
|
-
url: "/v1/checkout/sessions",
|
|
600
|
-
payload: VALID_BODY,
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
expect(res.statusCode).toBe(500);
|
|
604
|
-
expect(res.json().error).toBe("live-mode-forbidden");
|
|
605
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
606
|
-
});
|
|
607
|
-
|
|
608
|
-
// ── 500 — missing/empty key (#3757) ───────────────────────────────────────
|
|
609
|
-
|
|
610
|
-
it("returns 500 stripe-not-configured when STRIPE_SECRET_KEY is missing, not live-mode-forbidden", async () => {
|
|
611
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
612
|
-
|
|
613
|
-
const res = await app.inject({
|
|
614
|
-
method: "POST",
|
|
615
|
-
url: "/v1/checkout/sessions",
|
|
616
|
-
payload: VALID_BODY,
|
|
617
|
-
});
|
|
618
|
-
|
|
619
|
-
expect(res.statusCode).toBe(500);
|
|
620
|
-
expect(res.json().error).toBe("stripe-not-configured");
|
|
621
|
-
expect(mockAssertSandboxKey).not.toHaveBeenCalled();
|
|
622
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
it("returns 500 stripe-not-configured when STRIPE_SECRET_KEY is an empty string, not live-mode-forbidden", async () => {
|
|
626
|
-
process.env["STRIPE_SECRET_KEY"] = "";
|
|
627
|
-
|
|
628
|
-
const res = await app.inject({
|
|
629
|
-
method: "POST",
|
|
630
|
-
url: "/v1/checkout/sessions",
|
|
631
|
-
payload: VALID_BODY,
|
|
632
|
-
});
|
|
633
|
-
|
|
634
|
-
expect(res.statusCode).toBe(500);
|
|
635
|
-
expect(res.json().error).toBe("stripe-not-configured");
|
|
636
|
-
expect(mockAssertSandboxKey).not.toHaveBeenCalled();
|
|
637
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
638
|
-
});
|
|
639
|
-
});
|
|
640
|
-
|
|
641
|
-
// ── X-Internal-Auth gate tests (#935) ─────────────────────────────────────────
|
|
642
|
-
//
|
|
643
|
-
// These tests cover the env-var-conditional internal auth gate added by #935.
|
|
644
|
-
// The gate fires ONLY when OSS_API_INTERNAL_SECRET is set.
|
|
645
|
-
// When unset, the existing behaviour is unchanged (backward-compat for OSS standalone).
|
|
646
|
-
|
|
647
|
-
describe("POST /v1/checkout/sessions — X-Internal-Auth gate (#935)", () => {
|
|
648
|
-
let app: FastifyInstance;
|
|
649
|
-
// Value used as the OSS_API_INTERNAL_SECRET env var in gate tests.
|
|
650
|
-
const GATE_TOKEN = "x-auth-fixture-for-gate-tests";
|
|
651
|
-
|
|
652
|
-
beforeEach(async () => {
|
|
653
|
-
jest.clearAllMocks();
|
|
654
|
-
// Default: a present sandbox key (see the sibling describe block above).
|
|
655
|
-
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
656
|
-
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
657
|
-
mockCreateCheckoutSession.mockResolvedValue({
|
|
658
|
-
sessionId: "cs_test_abc12345xyz",
|
|
659
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_abc12345xyz",
|
|
660
|
-
});
|
|
661
|
-
|
|
662
|
-
app = buildApp();
|
|
663
|
-
await app.ready();
|
|
664
|
-
});
|
|
665
|
-
|
|
666
|
-
afterEach(async () => {
|
|
667
|
-
await app.close();
|
|
668
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
669
|
-
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
670
|
-
});
|
|
671
|
-
|
|
672
|
-
it("allows request when OSS_API_INTERNAL_SECRET is NOT set (backward-compat)", async () => {
|
|
673
|
-
// No env var set — gate is disabled, existing public path unchanged.
|
|
674
|
-
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
675
|
-
|
|
676
|
-
const res = await app.inject({
|
|
677
|
-
method: "POST",
|
|
678
|
-
url: "/v1/checkout/sessions",
|
|
679
|
-
payload: VALID_BODY,
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
// Gate not active — should pass through to Stripe logic (201 or 4xx from
|
|
683
|
-
// other checks, but NOT 401 internal-auth-required).
|
|
684
|
-
expect(res.statusCode).not.toBe(401);
|
|
685
|
-
expect(res.json().error).not.toBe("internal-auth-required");
|
|
686
|
-
});
|
|
687
|
-
|
|
688
|
-
it("allows request when X-Internal-Auth header matches the env var", async () => {
|
|
689
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
690
|
-
|
|
691
|
-
const res = await app.inject({
|
|
692
|
-
method: "POST",
|
|
693
|
-
url: "/v1/checkout/sessions",
|
|
694
|
-
headers: { "x-internal-auth": GATE_TOKEN },
|
|
695
|
-
payload: VALID_BODY,
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
expect(res.statusCode).toBe(201);
|
|
699
|
-
expect(res.json().error).toBeUndefined();
|
|
700
|
-
});
|
|
701
|
-
|
|
702
|
-
it("rejects with 401 when OSS_API_INTERNAL_SECRET is set but X-Internal-Auth header is missing", async () => {
|
|
703
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
704
|
-
|
|
705
|
-
const res = await app.inject({
|
|
706
|
-
method: "POST",
|
|
707
|
-
url: "/v1/checkout/sessions",
|
|
708
|
-
// No x-internal-auth header
|
|
709
|
-
payload: VALID_BODY,
|
|
710
|
-
});
|
|
711
|
-
|
|
712
|
-
expect(res.statusCode).toBe(401);
|
|
713
|
-
expect(res.json().error).toBe("internal-auth-required");
|
|
714
|
-
// Stripe must NOT be called when gate rejects
|
|
715
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
it("rejects with 401 when X-Internal-Auth header does not match", async () => {
|
|
719
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
720
|
-
|
|
721
|
-
const res = await app.inject({
|
|
722
|
-
method: "POST",
|
|
723
|
-
url: "/v1/checkout/sessions",
|
|
724
|
-
headers: { "x-internal-auth": "wrong-secret" },
|
|
725
|
-
payload: VALID_BODY,
|
|
726
|
-
});
|
|
727
|
-
|
|
728
|
-
expect(res.statusCode).toBe(401);
|
|
729
|
-
expect(res.json().error).toBe("internal-auth-required");
|
|
730
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
731
|
-
});
|
|
732
|
-
|
|
733
|
-
it("rejects with 401 when X-Internal-Auth header is present but empty", async () => {
|
|
734
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
735
|
-
|
|
736
|
-
const res = await app.inject({
|
|
737
|
-
method: "POST",
|
|
738
|
-
url: "/v1/checkout/sessions",
|
|
739
|
-
headers: { "x-internal-auth": "" },
|
|
740
|
-
payload: VALID_BODY,
|
|
741
|
-
});
|
|
742
|
-
|
|
743
|
-
expect(res.statusCode).toBe(401);
|
|
744
|
-
expect(res.json().error).toBe("internal-auth-required");
|
|
745
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
746
|
-
});
|
|
747
|
-
|
|
748
|
-
it("does NOT leak the internal secret in the 401 response body", async () => {
|
|
749
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
750
|
-
|
|
751
|
-
const res = await app.inject({
|
|
752
|
-
method: "POST",
|
|
753
|
-
url: "/v1/checkout/sessions",
|
|
754
|
-
headers: { "x-internal-auth": "wrong" },
|
|
755
|
-
payload: VALID_BODY,
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
expect(res.statusCode).toBe(401);
|
|
759
|
-
const body = JSON.stringify(res.json());
|
|
760
|
-
expect(body).not.toContain(GATE_TOKEN);
|
|
761
|
-
});
|
|
762
|
-
});
|
|
763
|
-
|
|
764
|
-
// ── API↔MCP parity (#5971) ───────────────────────────────────────────────────
|
|
765
|
-
|
|
766
|
-
/**
|
|
767
|
-
* The API half of the parity proof root `CLAUDE.md` § API ↔ MCP parity
|
|
768
|
-
* demands. `apps/api` cannot import `apps/mcp`, so neither side can call the
|
|
769
|
-
* other: the evidence is the SHARED SEAM asserted from both ends —
|
|
770
|
-
* `CreateSessionPayload` / `CheckoutSessionResponse` in
|
|
771
|
-
* `packages/api-client/src/checkout.ts`.
|
|
772
|
-
*
|
|
773
|
-
* The MCP half is `apps/mcp/__tests__/start_purchase.test.ts`, which pins that
|
|
774
|
-
* `ailkPurchase` calls `checkout.createSession` with exactly the payload
|
|
775
|
-
* objects below. Here the route receives them and its reply is pinned.
|
|
776
|
-
* Together: the tool builds exactly what the route accepts, and the route
|
|
777
|
-
* returns exactly what the tool reads.
|
|
778
|
-
*
|
|
779
|
-
* The route itself is UNTOUCHED by #5971 — this is a new `describe`, nothing
|
|
780
|
-
* more (spec NFR-4).
|
|
781
|
-
*/
|
|
782
|
-
describe("parity with the payload the MCP tool builds (#5971)", () => {
|
|
783
|
-
let app: FastifyInstance;
|
|
784
|
-
|
|
785
|
-
/** The payload `ailkPurchase` builds when every optional field is supplied. */
|
|
786
|
-
const MCP_PAYLOAD_FULL = {
|
|
787
|
-
productCode: "pro-plan",
|
|
788
|
-
quantity: 2,
|
|
789
|
-
successUrl: "https://example.com/thanks",
|
|
790
|
-
cancelUrl: "https://example.com/cancelled",
|
|
791
|
-
allowPromotionCodes: true,
|
|
792
|
-
};
|
|
793
|
-
|
|
794
|
-
/** The payload it builds from `{ productCode }` alone, with the redirect
|
|
795
|
-
* URLs derived from AILK_BASE_URL — the optional fields OMITTED, not
|
|
796
|
-
* undefined, which is what the tool's own suite pins. */
|
|
797
|
-
const MCP_PAYLOAD_MINIMAL = {
|
|
798
|
-
productCode: "pro-plan",
|
|
799
|
-
successUrl: "https://example.com/purchase/success",
|
|
800
|
-
cancelUrl: "https://example.com/purchase/cancel",
|
|
801
|
-
};
|
|
802
|
-
|
|
803
|
-
beforeEach(async () => {
|
|
804
|
-
jest.clearAllMocks();
|
|
805
|
-
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
806
|
-
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
807
|
-
mockCreateCheckoutSession.mockResolvedValue({
|
|
808
|
-
sessionId: "cs_test_parity",
|
|
809
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_parity",
|
|
810
|
-
});
|
|
811
|
-
|
|
812
|
-
app = buildApp();
|
|
813
|
-
await app.ready();
|
|
814
|
-
});
|
|
815
|
-
|
|
816
|
-
afterEach(async () => {
|
|
817
|
-
await app.close();
|
|
818
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
it("accepts the full MCP payload and returns 201 { sessionId, url }", async () => {
|
|
822
|
-
const res = await app.inject({
|
|
823
|
-
method: "POST",
|
|
824
|
-
url: "/v1/checkout/sessions",
|
|
825
|
-
payload: MCP_PAYLOAD_FULL,
|
|
826
|
-
});
|
|
827
|
-
|
|
828
|
-
expect(res.statusCode).toBe(201);
|
|
829
|
-
expect(res.json()).toEqual({
|
|
830
|
-
sessionId: "cs_test_parity",
|
|
831
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_parity",
|
|
832
|
-
});
|
|
833
|
-
});
|
|
834
|
-
|
|
835
|
-
it("accepts the minimal MCP payload and returns 201 { sessionId, url }", async () => {
|
|
836
|
-
const res = await app.inject({
|
|
837
|
-
method: "POST",
|
|
838
|
-
url: "/v1/checkout/sessions",
|
|
839
|
-
payload: MCP_PAYLOAD_MINIMAL,
|
|
840
|
-
});
|
|
841
|
-
|
|
842
|
-
expect(res.statusCode).toBe(201);
|
|
843
|
-
expect(res.json()).toEqual({
|
|
844
|
-
sessionId: "cs_test_parity",
|
|
845
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_parity",
|
|
846
|
-
});
|
|
847
|
-
});
|
|
848
|
-
|
|
849
|
-
it("forwards every field of the full MCP payload to Stripe unchanged", async () => {
|
|
850
|
-
await app.inject({
|
|
851
|
-
method: "POST",
|
|
852
|
-
url: "/v1/checkout/sessions",
|
|
853
|
-
payload: MCP_PAYLOAD_FULL,
|
|
854
|
-
});
|
|
855
|
-
|
|
856
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
857
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
858
|
-
expect.objectContaining({
|
|
859
|
-
productCode: "pro-plan",
|
|
860
|
-
quantity: 2,
|
|
861
|
-
successUrl: "https://example.com/thanks",
|
|
862
|
-
cancelUrl: "https://example.com/cancelled",
|
|
863
|
-
allowPromotionCodes: true,
|
|
864
|
-
}),
|
|
865
|
-
{ refuseProduct: isOfferingsProduct },
|
|
866
|
-
);
|
|
867
|
-
});
|
|
868
|
-
|
|
869
|
-
it("the `url` the tool surfaces is exactly the one this route returns", async () => {
|
|
870
|
-
const res = await app.inject({
|
|
871
|
-
method: "POST",
|
|
872
|
-
url: "/v1/checkout/sessions",
|
|
873
|
-
payload: MCP_PAYLOAD_MINIMAL,
|
|
874
|
-
});
|
|
875
|
-
|
|
876
|
-
// `ailkPurchase` returns `{ url: result.body.url }` — the same value,
|
|
877
|
-
// with `sessionId` deliberately withheld (spec D5).
|
|
878
|
-
expect(res.json().url).toBe(
|
|
879
|
-
"https://checkout.stripe.com/c/pay/cs_test_parity",
|
|
880
|
-
);
|
|
881
|
-
});
|
|
882
|
-
|
|
883
|
-
it("an unknown offering is the 404 the tool maps to its typed not_found body, and creates no session", async () => {
|
|
884
|
-
const { UnknownProductCodeError } = jest.requireMock(
|
|
885
|
-
"@working-theory/stripe-client",
|
|
886
|
-
) as { UnknownProductCodeError: new (code: string) => Error };
|
|
887
|
-
mockCreateCheckoutSession.mockRejectedValueOnce(
|
|
888
|
-
new UnknownProductCodeError("no-such-offering"),
|
|
889
|
-
);
|
|
890
|
-
|
|
891
|
-
const res = await app.inject({
|
|
892
|
-
method: "POST",
|
|
893
|
-
url: "/v1/checkout/sessions",
|
|
894
|
-
payload: { ...MCP_PAYLOAD_MINIMAL, productCode: "no-such-offering" },
|
|
895
|
-
});
|
|
896
|
-
|
|
897
|
-
expect(res.statusCode).toBe(404);
|
|
898
|
-
expect(res.json().error).toBe("unknown-product-code");
|
|
899
|
-
});
|
|
900
|
-
});
|
|
901
|
-
|
|
902
|
-
describe("CheckoutSessionRequestSchema preserves Stripe's {CHECKOUT_SESSION_ID} template token (#6084 AC-6)", () => {
|
|
903
|
-
let app: FastifyInstance;
|
|
904
|
-
|
|
905
|
-
beforeEach(async () => {
|
|
906
|
-
jest.clearAllMocks();
|
|
907
|
-
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
908
|
-
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
909
|
-
mockCreateCheckoutSession.mockResolvedValue({
|
|
910
|
-
sessionId: "cs_test_brace",
|
|
911
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_brace",
|
|
912
|
-
});
|
|
913
|
-
|
|
914
|
-
app = buildApp();
|
|
915
|
-
await app.ready();
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
afterEach(async () => {
|
|
919
|
-
await app.close();
|
|
920
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
921
|
-
});
|
|
922
|
-
|
|
923
|
-
it("a successUrl carrying the literal ?session_id={CHECKOUT_SESSION_ID} token passes validation and reaches Stripe unmodified", async () => {
|
|
924
|
-
const successUrlWithToken =
|
|
925
|
-
"https://example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}";
|
|
926
|
-
|
|
927
|
-
const res = await app.inject({
|
|
928
|
-
method: "POST",
|
|
929
|
-
url: "/v1/checkout/sessions",
|
|
930
|
-
payload: { ...VALID_BODY, successUrl: successUrlWithToken },
|
|
931
|
-
});
|
|
932
|
-
|
|
933
|
-
// The schema's `isValidRedirectUrl` refine runs `new URL(value)` —
|
|
934
|
-
// WHATWG's URL parser is lenient with `{`/`}` in a query string and
|
|
935
|
-
// preserves them verbatim, unlike strict RFC 3986 parsers that would
|
|
936
|
-
// percent-encode or reject them. A 400 here would mean Stripe's own
|
|
937
|
-
// documented template mechanism (#6084 D1) cannot survive this
|
|
938
|
-
// schema at all.
|
|
939
|
-
expect(res.statusCode).toBe(201);
|
|
940
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
941
|
-
expect.objectContaining({ successUrl: successUrlWithToken }),
|
|
942
|
-
{ refuseProduct: isOfferingsProduct },
|
|
943
|
-
);
|
|
944
|
-
});
|
|
945
|
-
});
|
|
946
|
-
|
|
947
|
-
// ── Signed-in buyer stamp (#6866) ────────────────────────────────────────────
|
|
948
|
-
|
|
949
|
-
describe("POST /v1/checkout/sessions — signed-in buyer stamp (#6866)", () => {
|
|
950
|
-
const SESSION_TOKEN = "sess_tok_buyer";
|
|
951
|
-
const COOKIE = `better-auth.session_token=${SESSION_TOKEN}.signature`;
|
|
952
|
-
const SAME_ORIGIN = config.FRONTEND_URL;
|
|
953
|
-
const originalDatabaseUrl = process.env.DATABASE_URL;
|
|
954
|
-
|
|
955
|
-
type UserRow = {
|
|
956
|
-
id: string;
|
|
957
|
-
email: string;
|
|
958
|
-
emailVerified: boolean;
|
|
959
|
-
billingProvider: string | null;
|
|
960
|
-
billingCustomerId: string | null;
|
|
961
|
-
};
|
|
962
|
-
|
|
963
|
-
let app: FastifyInstance;
|
|
964
|
-
let logLines: Array<Record<string, unknown>>;
|
|
965
|
-
let rawLog: string;
|
|
966
|
-
|
|
967
|
-
function givenSession(overrides: Record<string, unknown> = {}) {
|
|
968
|
-
(prisma.session.findUnique as jest.Mock).mockResolvedValue({
|
|
969
|
-
id: "ses_1",
|
|
970
|
-
userId: "usr_buyer",
|
|
971
|
-
expiresAt: new Date(Date.now() + 60_000),
|
|
972
|
-
activeOrganizationId: null,
|
|
973
|
-
activeWorkspaceId: null,
|
|
974
|
-
...overrides,
|
|
975
|
-
});
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
/** Answers each `user.findUnique` with only the fields it selected. */
|
|
979
|
-
function givenUser(row: Partial<UserRow> | null) {
|
|
980
|
-
const full: UserRow = {
|
|
981
|
-
id: "usr_buyer",
|
|
982
|
-
email: "buyer@example.com",
|
|
983
|
-
emailVerified: true,
|
|
984
|
-
billingProvider: null,
|
|
985
|
-
billingCustomerId: null,
|
|
986
|
-
...row,
|
|
987
|
-
};
|
|
988
|
-
(prisma.user.findUnique as jest.Mock).mockImplementation(
|
|
989
|
-
async (args: { select: Record<string, boolean> }) =>
|
|
990
|
-
row === null
|
|
991
|
-
? null
|
|
992
|
-
: Object.fromEntries(
|
|
993
|
-
Object.keys(args.select).map((k) => [
|
|
994
|
-
k,
|
|
995
|
-
full[k as keyof UserRow],
|
|
996
|
-
]),
|
|
997
|
-
),
|
|
998
|
-
);
|
|
999
|
-
}
|
|
1000
|
-
|
|
1001
|
-
function post(
|
|
1002
|
-
headers: Record<string, string>,
|
|
1003
|
-
payload: Record<string, unknown> = VALID_BODY,
|
|
1004
|
-
) {
|
|
1005
|
-
return app.inject({
|
|
1006
|
-
method: "POST",
|
|
1007
|
-
url: "/v1/checkout/sessions",
|
|
1008
|
-
headers,
|
|
1009
|
-
payload,
|
|
1010
|
-
});
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
function signedIn(payload?: Record<string, unknown>) {
|
|
1014
|
-
return post({ cookie: COOKIE, origin: SAME_ORIGIN }, payload);
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function createdLog() {
|
|
1018
|
-
return logLines.find((l) => l["msg"] === "[checkout] session created");
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
beforeEach(async () => {
|
|
1022
|
-
jest.clearAllMocks();
|
|
1023
|
-
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
1024
|
-
process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
|
|
1025
|
-
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
1026
|
-
mockCreateCheckoutSession.mockResolvedValue({
|
|
1027
|
-
sessionId: "cs_test_abc12345xyz",
|
|
1028
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_abc12345xyz",
|
|
1029
|
-
});
|
|
1030
|
-
givenSession();
|
|
1031
|
-
givenUser({});
|
|
1032
|
-
|
|
1033
|
-
logLines = [];
|
|
1034
|
-
rawLog = "";
|
|
1035
|
-
app = require("fastify")({
|
|
1036
|
-
logger: {
|
|
1037
|
-
level: "info",
|
|
1038
|
-
stream: {
|
|
1039
|
-
write: (line: string) => {
|
|
1040
|
-
rawLog += line;
|
|
1041
|
-
logLines.push(JSON.parse(line) as Record<string, unknown>);
|
|
1042
|
-
},
|
|
1043
|
-
},
|
|
1044
|
-
},
|
|
1045
|
-
});
|
|
1046
|
-
app.register(checkoutRoutes);
|
|
1047
|
-
await app.ready();
|
|
1048
|
-
});
|
|
1049
|
-
|
|
1050
|
-
afterEach(async () => {
|
|
1051
|
-
await app.close();
|
|
1052
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
1053
|
-
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
1054
|
-
else process.env.DATABASE_URL = originalDatabaseUrl;
|
|
1055
|
-
});
|
|
1056
|
-
|
|
1057
|
-
it("#6866 R1: an unbound verified buyer is passed from the Session row, never the body", async () => {
|
|
1058
|
-
const res = await signedIn({ ...VALID_BODY, ailk_user_id: "usr_attacker" });
|
|
1059
|
-
|
|
1060
|
-
expect(res.statusCode).toBe(201);
|
|
1061
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
1062
|
-
const arg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
1063
|
-
expect(arg.buyer).toEqual({
|
|
1064
|
-
userId: "usr_buyer",
|
|
1065
|
-
email: "buyer@example.com",
|
|
1066
|
-
});
|
|
1067
|
-
expect(arg.buyer).not.toHaveProperty("customerId");
|
|
1068
|
-
expect(prisma.session.findUnique).toHaveBeenCalledWith(
|
|
1069
|
-
expect.objectContaining({ where: { token: SESSION_TOKEN } }),
|
|
1070
|
-
);
|
|
1071
|
-
});
|
|
1072
|
-
|
|
1073
|
-
it("#6866 R2: a stripe-bound buyer passes their customerId", async () => {
|
|
1074
|
-
givenUser({ billingProvider: "stripe", billingCustomerId: "cus_bound" });
|
|
1075
|
-
|
|
1076
|
-
await signedIn();
|
|
1077
|
-
|
|
1078
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0].buyer.customerId).toBe(
|
|
1079
|
-
"cus_bound",
|
|
1080
|
-
);
|
|
1081
|
-
});
|
|
1082
|
-
|
|
1083
|
-
it("#6866 R2b: a buyer bound to another provider checks out anonymously", async () => {
|
|
1084
|
-
givenUser({ billingProvider: "paddle", billingCustomerId: "ctm_other" });
|
|
1085
|
-
|
|
1086
|
-
const res = await signedIn();
|
|
1087
|
-
|
|
1088
|
-
expect(res.statusCode).toBe(201);
|
|
1089
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0]).not.toHaveProperty(
|
|
1090
|
-
"buyer",
|
|
1091
|
-
);
|
|
1092
|
-
expect(createdLog()).toMatchObject({
|
|
1093
|
-
buyer: "anonymous",
|
|
1094
|
-
identity: "foreign_provider",
|
|
1095
|
-
});
|
|
1096
|
-
});
|
|
1097
|
-
|
|
1098
|
-
it("#6866 R3: an anonymous body-supplied ailk_user_id reaches neither buyer nor metadata", async () => {
|
|
1099
|
-
const res = await post(
|
|
1100
|
-
{},
|
|
1101
|
-
{
|
|
1102
|
-
...VALID_BODY,
|
|
1103
|
-
ailk_user_id: "usr_attacker",
|
|
1104
|
-
metadata: { ailk_user_id: "usr_attacker" },
|
|
1105
|
-
},
|
|
1106
|
-
);
|
|
1107
|
-
|
|
1108
|
-
expect(res.statusCode).toBe(201);
|
|
1109
|
-
const arg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
1110
|
-
expect(arg).not.toHaveProperty("buyer");
|
|
1111
|
-
// #7868 D4 — the only metadata is the hub's own site stamp.
|
|
1112
|
-
expect(arg.metadata).toEqual({ site: "default" });
|
|
1113
|
-
expect(arg).not.toHaveProperty("ailk_user_id");
|
|
1114
|
-
});
|
|
1115
|
-
|
|
1116
|
-
it("#6866 R4: signed in, a body-supplied ailk_user_id loses to the session's user", async () => {
|
|
1117
|
-
await signedIn({
|
|
1118
|
-
...VALID_BODY,
|
|
1119
|
-
ailk_user_id: "usr_attacker",
|
|
1120
|
-
metadata: { ailk_user_id: "usr_attacker" },
|
|
1121
|
-
});
|
|
1122
|
-
|
|
1123
|
-
const arg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
1124
|
-
expect(arg.buyer.userId).toBe("usr_buyer");
|
|
1125
|
-
// #7868 D4 — the only metadata is the hub's own site stamp.
|
|
1126
|
-
expect(arg.metadata).toEqual({ site: "default" });
|
|
1127
|
-
});
|
|
1128
|
-
|
|
1129
|
-
it("#6866 R5: with no cookie, the call is exactly today's argument object", async () => {
|
|
1130
|
-
const res = await post({});
|
|
1131
|
-
|
|
1132
|
-
expect(res.statusCode).toBe(201);
|
|
1133
|
-
expect(res.json()).toEqual({
|
|
1134
|
-
sessionId: "cs_test_abc12345xyz",
|
|
1135
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_abc12345xyz",
|
|
1136
|
-
});
|
|
1137
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0]).toEqual({
|
|
1138
|
-
productCode: VALID_BODY.productCode,
|
|
1139
|
-
successUrl: VALID_BODY.successUrl,
|
|
1140
|
-
cancelUrl: VALID_BODY.cancelUrl,
|
|
1141
|
-
quantity: 1,
|
|
1142
|
-
// #7868 D4 — the hub's own site stamp (AILK_SITE_ID unset).
|
|
1143
|
-
metadata: { site: "default" },
|
|
1144
|
-
});
|
|
1145
|
-
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
|
1146
|
-
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
|
1147
|
-
expect(createdLog()).toMatchObject({ buyer: "anonymous" });
|
|
1148
|
-
expect(createdLog()).not.toHaveProperty("identity");
|
|
1149
|
-
});
|
|
1150
|
-
|
|
1151
|
-
it("#7868 D4: the site stamp follows AILK_SITE_ID", async () => {
|
|
1152
|
-
const original = process.env.AILK_SITE_ID;
|
|
1153
|
-
process.env.AILK_SITE_ID = "wksp_hub";
|
|
1154
|
-
try {
|
|
1155
|
-
const res = await post({});
|
|
1156
|
-
|
|
1157
|
-
expect(res.statusCode).toBe(201);
|
|
1158
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0].metadata).toEqual({
|
|
1159
|
-
site: "wksp_hub",
|
|
1160
|
-
});
|
|
1161
|
-
} finally {
|
|
1162
|
-
if (original === undefined) delete process.env.AILK_SITE_ID;
|
|
1163
|
-
else process.env.AILK_SITE_ID = original;
|
|
1164
|
-
}
|
|
1165
|
-
});
|
|
1166
|
-
|
|
1167
|
-
it("#7868 D4: the anonymous retry after a stale customer carries the site stamp too", async () => {
|
|
1168
|
-
givenUser({ billingProvider: "stripe", billingCustomerId: "cus_bound" });
|
|
1169
|
-
mockCreateCheckoutSession.mockRejectedValueOnce(new UnknownCustomerError());
|
|
1170
|
-
|
|
1171
|
-
const res = await signedIn();
|
|
1172
|
-
|
|
1173
|
-
expect(res.statusCode).toBe(201);
|
|
1174
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(2);
|
|
1175
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0].metadata).toEqual({
|
|
1176
|
-
site: "default",
|
|
1177
|
-
});
|
|
1178
|
-
expect(mockCreateCheckoutSession.mock.calls[1][0].metadata).toEqual({
|
|
1179
|
-
site: "default",
|
|
1180
|
-
});
|
|
1181
|
-
});
|
|
1182
|
-
|
|
1183
|
-
it("#7868 D4: a signed-in call carries both the buyer and the site stamp", async () => {
|
|
1184
|
-
const res = await signedIn();
|
|
1185
|
-
|
|
1186
|
-
expect(res.statusCode).toBe(201);
|
|
1187
|
-
const arg = mockCreateCheckoutSession.mock.calls[0][0];
|
|
1188
|
-
expect(arg.buyer).toEqual({
|
|
1189
|
-
userId: "usr_buyer",
|
|
1190
|
-
email: "buyer@example.com",
|
|
1191
|
-
});
|
|
1192
|
-
expect(arg.metadata).toEqual({ site: "default" });
|
|
1193
|
-
});
|
|
1194
|
-
|
|
1195
|
-
it.each([
|
|
1196
|
-
[
|
|
1197
|
-
"an unknown session",
|
|
1198
|
-
() => (prisma.session.findUnique as jest.Mock).mockResolvedValue(null),
|
|
1199
|
-
{},
|
|
1200
|
-
"unknown_session",
|
|
1201
|
-
],
|
|
1202
|
-
[
|
|
1203
|
-
"an expired session",
|
|
1204
|
-
() => givenSession({ expiresAt: new Date(Date.now() - 1_000) }),
|
|
1205
|
-
{},
|
|
1206
|
-
"expired",
|
|
1207
|
-
],
|
|
1208
|
-
[
|
|
1209
|
-
"a cross-origin Origin",
|
|
1210
|
-
() => undefined,
|
|
1211
|
-
{ origin: "https://evil.example.com" },
|
|
1212
|
-
"cross_origin",
|
|
1213
|
-
],
|
|
1214
|
-
[
|
|
1215
|
-
"a rejecting session lookup",
|
|
1216
|
-
() =>
|
|
1217
|
-
(prisma.session.findUnique as jest.Mock).mockRejectedValue(
|
|
1218
|
-
new Error("db down"),
|
|
1219
|
-
),
|
|
1220
|
-
{},
|
|
1221
|
-
"lookup_failed",
|
|
1222
|
-
],
|
|
1223
|
-
[
|
|
1224
|
-
"an unverified email",
|
|
1225
|
-
() => givenUser({ emailVerified: false }),
|
|
1226
|
-
{},
|
|
1227
|
-
"email_unverified",
|
|
1228
|
-
],
|
|
1229
|
-
["a missing User row", () => givenUser(null), {}, "user_not_found"],
|
|
1230
|
-
[
|
|
1231
|
-
"a rejecting user lookup after a valid cookie",
|
|
1232
|
-
() =>
|
|
1233
|
-
(prisma.user.findUnique as jest.Mock).mockRejectedValue(
|
|
1234
|
-
new Error("db down"),
|
|
1235
|
-
),
|
|
1236
|
-
{},
|
|
1237
|
-
"buyer_lookup_failed",
|
|
1238
|
-
],
|
|
1239
|
-
])(
|
|
1240
|
-
"#6866 R6: %s is an anonymous 201, never an error",
|
|
1241
|
-
async (_label, arrange, headers, identity) => {
|
|
1242
|
-
arrange();
|
|
1243
|
-
|
|
1244
|
-
const res = await post({
|
|
1245
|
-
cookie: COOKIE,
|
|
1246
|
-
origin: SAME_ORIGIN,
|
|
1247
|
-
...(headers as Record<string, string>),
|
|
1248
|
-
});
|
|
1249
|
-
|
|
1250
|
-
expect(res.statusCode).toBe(201);
|
|
1251
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0]).not.toHaveProperty(
|
|
1252
|
-
"buyer",
|
|
1253
|
-
);
|
|
1254
|
-
expect(createdLog()).toMatchObject({ buyer: "anonymous", identity });
|
|
1255
|
-
},
|
|
1256
|
-
);
|
|
1257
|
-
|
|
1258
|
-
it("#6866 R7: a bearer token alone never stamps, and apiToken is never read", async () => {
|
|
1259
|
-
const res = await post({ authorization: "Bearer tok_valid_looking_123" });
|
|
1260
|
-
|
|
1261
|
-
expect(res.statusCode).toBe(201);
|
|
1262
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0]).not.toHaveProperty(
|
|
1263
|
-
"buyer",
|
|
1264
|
-
);
|
|
1265
|
-
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
|
|
1266
|
-
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
|
1267
|
-
});
|
|
1268
|
-
|
|
1269
|
-
it("#6866 R8: the log names the buyer kind, never the user, email, customer or cookie", async () => {
|
|
1270
|
-
givenUser({ billingProvider: "stripe", billingCustomerId: "cus_bound" });
|
|
1271
|
-
|
|
1272
|
-
await signedIn();
|
|
1273
|
-
|
|
1274
|
-
expect(createdLog()).toMatchObject({ buyer: "signed_in" });
|
|
1275
|
-
expect(createdLog()).not.toHaveProperty("identity");
|
|
1276
|
-
expect(rawLog).not.toContain("usr_buyer");
|
|
1277
|
-
expect(rawLog).not.toContain("buyer@example.com");
|
|
1278
|
-
expect(rawLog).not.toContain("cus_");
|
|
1279
|
-
expect(rawLog).not.toContain(SESSION_TOKEN);
|
|
1280
|
-
});
|
|
1281
|
-
|
|
1282
|
-
it("#6866 R9: a cross-origin cookie logs identity cross_origin", async () => {
|
|
1283
|
-
await post({ cookie: COOKIE, origin: "https://evil.example.com" });
|
|
1284
|
-
|
|
1285
|
-
expect(createdLog()).toMatchObject({
|
|
1286
|
-
buyer: "anonymous",
|
|
1287
|
-
identity: "cross_origin",
|
|
1288
|
-
});
|
|
1289
|
-
});
|
|
1290
|
-
|
|
1291
|
-
it("#6866 R10: a generic Stripe failure on a signed-in call is still 502 checkout-unavailable", async () => {
|
|
1292
|
-
mockCreateCheckoutSession.mockRejectedValue(new Error("stripe down"));
|
|
1293
|
-
|
|
1294
|
-
const res = await signedIn();
|
|
1295
|
-
|
|
1296
|
-
expect(res.statusCode).toBe(502);
|
|
1297
|
-
expect(res.json()).toEqual({ error: "checkout-unavailable" });
|
|
1298
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
1299
|
-
});
|
|
1300
|
-
|
|
1301
|
-
it("#6866 R12: a buyer the library's schema refuses retries once as anonymous (F7)", async () => {
|
|
1302
|
-
givenUser({ billingProvider: "stripe", billingCustomerId: "not_a_cus" });
|
|
1303
|
-
mockCreateCheckoutSession.mockRejectedValueOnce(
|
|
1304
|
-
Object.assign(new Error("invalid buyer"), { name: "ZodError" }),
|
|
1305
|
-
);
|
|
1306
|
-
|
|
1307
|
-
const res = await signedIn();
|
|
1308
|
-
|
|
1309
|
-
expect(res.statusCode).toBe(201);
|
|
1310
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(2);
|
|
1311
|
-
expect(mockCreateCheckoutSession.mock.calls[1][0]).not.toHaveProperty(
|
|
1312
|
-
"buyer",
|
|
1313
|
-
);
|
|
1314
|
-
expect(createdLog()).toMatchObject({
|
|
1315
|
-
buyer: "anonymous",
|
|
1316
|
-
identity: "buyer_invalid",
|
|
1317
|
-
});
|
|
1318
|
-
});
|
|
1319
|
-
|
|
1320
|
-
it("#8931 A9: the first try and the anonymous retry both refuse an Offerings product with the marker itself", async () => {
|
|
1321
|
-
givenUser({ billingProvider: "stripe", billingCustomerId: "not_a_cus" });
|
|
1322
|
-
mockCreateCheckoutSession.mockRejectedValueOnce(
|
|
1323
|
-
Object.assign(new Error("invalid buyer"), { name: "ZodError" }),
|
|
1324
|
-
);
|
|
1325
|
-
|
|
1326
|
-
const res = await signedIn();
|
|
1327
|
-
|
|
1328
|
-
expect(res.statusCode).toBe(201);
|
|
1329
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(2);
|
|
1330
|
-
for (const call of mockCreateCheckoutSession.mock.calls) {
|
|
1331
|
-
expect(call).toHaveLength(2);
|
|
1332
|
-
expect(Object.keys(call[1])).toEqual(["refuseProduct"]);
|
|
1333
|
-
expect(call[1].refuseProduct).toBe(isOfferingsProduct);
|
|
1334
|
-
}
|
|
1335
|
-
});
|
|
1336
|
-
|
|
1337
|
-
it("#6866 R12b: an anonymous ZodError is not retried — today's 502 mapping", async () => {
|
|
1338
|
-
mockCreateCheckoutSession.mockRejectedValue(
|
|
1339
|
-
Object.assign(new Error("invalid"), { name: "ZodError" }),
|
|
1340
|
-
);
|
|
1341
|
-
|
|
1342
|
-
const res = await post({});
|
|
1343
|
-
|
|
1344
|
-
expect(res.statusCode).toBe(502);
|
|
1345
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(1);
|
|
1346
|
-
});
|
|
1347
|
-
|
|
1348
|
-
it("#6866 R11: a pinned customer Stripe rejects retries once as anonymous", async () => {
|
|
1349
|
-
givenUser({ billingProvider: "stripe", billingCustomerId: "cus_bound" });
|
|
1350
|
-
mockCreateCheckoutSession.mockRejectedValueOnce(new UnknownCustomerError());
|
|
1351
|
-
|
|
1352
|
-
const res = await signedIn();
|
|
1353
|
-
|
|
1354
|
-
expect(res.statusCode).toBe(201);
|
|
1355
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledTimes(2);
|
|
1356
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0].buyer.customerId).toBe(
|
|
1357
|
-
"cus_bound",
|
|
1358
|
-
);
|
|
1359
|
-
expect(mockCreateCheckoutSession.mock.calls[1][0]).not.toHaveProperty(
|
|
1360
|
-
"buyer",
|
|
1361
|
-
);
|
|
1362
|
-
expect(createdLog()).toMatchObject({
|
|
1363
|
-
buyer: "anonymous",
|
|
1364
|
-
identity: "stale_customer",
|
|
1365
|
-
});
|
|
1366
|
-
});
|
|
1367
|
-
});
|
|
1368
|
-
|
|
1369
|
-
// ── Same-origin redirect lock (#6877) ─────────────────────────────────────────
|
|
1370
|
-
|
|
1371
|
-
describe("POST /v1/checkout/sessions — same-origin redirect lock (#6877)", () => {
|
|
1372
|
-
const SITE_ORIGIN = "https://example.com";
|
|
1373
|
-
const GATE_TOKEN = "x-auth-fixture-for-6877";
|
|
1374
|
-
const SESSION_TOKEN = "sess_tok_6877";
|
|
1375
|
-
const COOKIE = `better-auth.session_token=${SESSION_TOKEN}.signature`;
|
|
1376
|
-
const originalDatabaseUrl = process.env.DATABASE_URL;
|
|
1377
|
-
|
|
1378
|
-
let app: FastifyInstance;
|
|
1379
|
-
let logLines: Array<Record<string, unknown>>;
|
|
1380
|
-
let rawLog: string;
|
|
1381
|
-
|
|
1382
|
-
function post(
|
|
1383
|
-
payload: Record<string, unknown>,
|
|
1384
|
-
headers: Record<string, string> = {},
|
|
1385
|
-
) {
|
|
1386
|
-
return app.inject({
|
|
1387
|
-
method: "POST",
|
|
1388
|
-
url: "/v1/checkout/sessions",
|
|
1389
|
-
headers,
|
|
1390
|
-
payload,
|
|
1391
|
-
});
|
|
1392
|
-
}
|
|
1393
|
-
|
|
1394
|
-
beforeEach(async () => {
|
|
1395
|
-
jest.clearAllMocks();
|
|
1396
|
-
config.FRONTEND_URL = SITE_ORIGIN;
|
|
1397
|
-
config.TRUSTED_ORIGINS = [SITE_ORIGIN];
|
|
1398
|
-
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
1399
|
-
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
1400
|
-
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
1401
|
-
mockCreateCheckoutSession.mockResolvedValue({
|
|
1402
|
-
sessionId: "cs_test_lock6877",
|
|
1403
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_lock6877",
|
|
1404
|
-
});
|
|
1405
|
-
|
|
1406
|
-
logLines = [];
|
|
1407
|
-
rawLog = "";
|
|
1408
|
-
app = require("fastify")({
|
|
1409
|
-
logger: {
|
|
1410
|
-
level: "info",
|
|
1411
|
-
stream: {
|
|
1412
|
-
write: (line: string) => {
|
|
1413
|
-
rawLog += line;
|
|
1414
|
-
logLines.push(JSON.parse(line) as Record<string, unknown>);
|
|
1415
|
-
},
|
|
1416
|
-
},
|
|
1417
|
-
},
|
|
1418
|
-
});
|
|
1419
|
-
app.register(checkoutRoutes);
|
|
1420
|
-
await app.ready();
|
|
1421
|
-
});
|
|
1422
|
-
|
|
1423
|
-
afterEach(async () => {
|
|
1424
|
-
await app.close();
|
|
1425
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
1426
|
-
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
1427
|
-
config.FRONTEND_URL = SITE_ORIGIN;
|
|
1428
|
-
config.TRUSTED_ORIGINS = [SITE_ORIGIN];
|
|
1429
|
-
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
1430
|
-
else process.env.DATABASE_URL = originalDatabaseUrl;
|
|
1431
|
-
});
|
|
1432
|
-
|
|
1433
|
-
it("#6877 R1: an off-origin successUrl is refused with 400 and no Stripe call", async () => {
|
|
1434
|
-
const res = await post({
|
|
1435
|
-
...VALID_BODY,
|
|
1436
|
-
successUrl: "https://evil.test/x",
|
|
1437
|
-
});
|
|
1438
|
-
|
|
1439
|
-
expect(res.statusCode).toBe(400);
|
|
1440
|
-
const body = res.json();
|
|
1441
|
-
expect(body.error).toBe("validation-error");
|
|
1442
|
-
expect(body.details).toHaveLength(1);
|
|
1443
|
-
expect(body.details[0].path).toEqual(["successUrl"]);
|
|
1444
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1445
|
-
});
|
|
1446
|
-
|
|
1447
|
-
it("#6877 R2: an off-origin cancelUrl alone is refused with 400 and no Stripe call", async () => {
|
|
1448
|
-
const res = await post({ ...VALID_BODY, cancelUrl: "https://evil.test/x" });
|
|
1449
|
-
|
|
1450
|
-
expect(res.statusCode).toBe(400);
|
|
1451
|
-
expect(res.json().details[0].path).toEqual(["cancelUrl"]);
|
|
1452
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1453
|
-
});
|
|
1454
|
-
|
|
1455
|
-
/** Lookalikes WHATWG still parses to the site origin — refused only by the
|
|
1456
|
-
* raw-string checks (D2 checks 1 and 2). */
|
|
1457
|
-
const PARSER_DIFFERENTIAL = [
|
|
1458
|
-
"https://example.com\\@evil.test/x",
|
|
1459
|
-
"https://evil.test%40@example.com/",
|
|
1460
|
-
"https:example.com/x",
|
|
1461
|
-
"https://\uff45xample.com/x",
|
|
1462
|
-
" https://example.com/x",
|
|
1463
|
-
"https://example.com\t/x",
|
|
1464
|
-
];
|
|
1465
|
-
|
|
1466
|
-
it("#6877 R3 precondition: every parser-differential row parses to the site origin", () => {
|
|
1467
|
-
for (const raw of PARSER_DIFFERENTIAL) {
|
|
1468
|
-
expect(new URL(raw).origin).toBe(SITE_ORIGIN);
|
|
1469
|
-
}
|
|
1470
|
-
});
|
|
1471
|
-
|
|
1472
|
-
it.each([
|
|
1473
|
-
"https://example.com.evil.test/x",
|
|
1474
|
-
"https://example.com@evil.test/x",
|
|
1475
|
-
"http://example.com/x",
|
|
1476
|
-
"https://example.com:8443/x",
|
|
1477
|
-
...PARSER_DIFFERENTIAL,
|
|
1478
|
-
])("#6877 R3: the lookalike %j is refused", async (successUrl) => {
|
|
1479
|
-
const res = await post({ ...VALID_BODY, successUrl });
|
|
1480
|
-
|
|
1481
|
-
expect(res.statusCode).toBe(400);
|
|
1482
|
-
expect(res.json().error).toBe("validation-error");
|
|
1483
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1484
|
-
});
|
|
1485
|
-
|
|
1486
|
-
it("#6877 R3b: a raw space on the site origin is refused (D2 check 1)", async () => {
|
|
1487
|
-
const res = await post({
|
|
1488
|
-
...VALID_BODY,
|
|
1489
|
-
successUrl: "https://example.com/x y",
|
|
1490
|
-
});
|
|
1491
|
-
|
|
1492
|
-
expect(res.statusCode).toBe(400);
|
|
1493
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1494
|
-
});
|
|
1495
|
-
|
|
1496
|
-
it("#6877 R4: the refused URL is never echoed or logged", async () => {
|
|
1497
|
-
const res = await post({
|
|
1498
|
-
...VALID_BODY,
|
|
1499
|
-
successUrl: "https://evil.test/x",
|
|
1500
|
-
});
|
|
1501
|
-
|
|
1502
|
-
expect(res.statusCode).toBe(400);
|
|
1503
|
-
expect(res.body).not.toContain("evil.test");
|
|
1504
|
-
expect(rawLog).not.toContain("evil.test");
|
|
1505
|
-
const refusals = logLines.filter(
|
|
1506
|
-
(l) => l["outcome"] === "redirect_origin_refused",
|
|
1507
|
-
);
|
|
1508
|
-
expect(refusals).toHaveLength(1);
|
|
1509
|
-
expect(refusals[0]).toMatchObject({ fields: ["successUrl"] });
|
|
1510
|
-
});
|
|
1511
|
-
|
|
1512
|
-
it("#6877 R5: a same-origin checkout is forwarded byte-for-byte", async () => {
|
|
1513
|
-
const successUrl =
|
|
1514
|
-
"https://example.com/purchase/success?session_id={CHECKOUT_SESSION_ID}";
|
|
1515
|
-
const cancelUrl = "https://example.com/purchase/cancel";
|
|
1516
|
-
|
|
1517
|
-
const res = await post({ productCode: "ailk-pro", successUrl, cancelUrl });
|
|
1518
|
-
|
|
1519
|
-
expect(res.statusCode).toBe(201);
|
|
1520
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1521
|
-
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1522
|
-
{ refuseProduct: isOfferingsProduct },
|
|
1523
|
-
);
|
|
1524
|
-
});
|
|
1525
|
-
|
|
1526
|
-
it("#6877 R6: an authenticated internal caller keeps its two-host redirects", async () => {
|
|
1527
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
1528
|
-
const successUrl = "https://app.ailaunchkit.ai/dashboard?checkout=success";
|
|
1529
|
-
const cancelUrl = "https://ailaunchkit.ai/pricing?checkout=cancel";
|
|
1530
|
-
|
|
1531
|
-
const res = await post(
|
|
1532
|
-
{ productCode: "ailk-pro", successUrl, cancelUrl },
|
|
1533
|
-
{ "x-internal-auth": GATE_TOKEN },
|
|
1534
|
-
);
|
|
1535
|
-
|
|
1536
|
-
expect(res.statusCode).toBe(201);
|
|
1537
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1538
|
-
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1539
|
-
{ refuseProduct: isOfferingsProduct },
|
|
1540
|
-
);
|
|
1541
|
-
});
|
|
1542
|
-
|
|
1543
|
-
it("#6877 R7: the gate refuses a missing header with 401 before any origin check", async () => {
|
|
1544
|
-
process.env["OSS_API_INTERNAL_SECRET"] = GATE_TOKEN;
|
|
1545
|
-
|
|
1546
|
-
const res = await post({
|
|
1547
|
-
...VALID_BODY,
|
|
1548
|
-
successUrl: "https://evil.test/x",
|
|
1549
|
-
});
|
|
1550
|
-
|
|
1551
|
-
expect(res.statusCode).toBe(401);
|
|
1552
|
-
expect(res.json()).toEqual({ error: "internal-auth-required" });
|
|
1553
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1554
|
-
});
|
|
1555
|
-
|
|
1556
|
-
// #7395 D4 — what an unusable or unset FRONTEND_URL did here before
|
|
1557
|
-
// (#6877 R8) is now an empty trusted list: config resolves both to `[]`
|
|
1558
|
-
// (config/__tests__/trusted-origins.test.ts), and the route answers the
|
|
1559
|
-
// same 500.
|
|
1560
|
-
it("#6877 R8: an empty trusted-origin list answers 500 config-error with no Stripe call", async () => {
|
|
1561
|
-
config.TRUSTED_ORIGINS = [];
|
|
1562
|
-
|
|
1563
|
-
const res = await post(VALID_BODY);
|
|
1564
|
-
|
|
1565
|
-
expect(res.statusCode).toBe(500);
|
|
1566
|
-
expect(res.json()).toEqual({ error: "config-error" });
|
|
1567
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1568
|
-
expect(
|
|
1569
|
-
logLines.some(
|
|
1570
|
-
(l) => l["msg"] === "[checkout] no trusted browser origin is configured",
|
|
1571
|
-
),
|
|
1572
|
-
).toBe(true);
|
|
1573
|
-
});
|
|
1574
|
-
|
|
1575
|
-
it("#6877 R9: a refused request never reads the session cookie's row", async () => {
|
|
1576
|
-
process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
|
|
1577
|
-
(prisma.session.findUnique as jest.Mock).mockResolvedValue(null);
|
|
1578
|
-
const headers = { cookie: COOKIE, origin: SITE_ORIGIN };
|
|
1579
|
-
|
|
1580
|
-
// Control: the same signed-in request on-origin DOES reach the lookup.
|
|
1581
|
-
const control = await post(VALID_BODY, headers);
|
|
1582
|
-
expect(control.statusCode).toBe(201);
|
|
1583
|
-
expect(prisma.session.findUnique).toHaveBeenCalled();
|
|
1584
|
-
jest.clearAllMocks();
|
|
1585
|
-
|
|
1586
|
-
const res = await post(
|
|
1587
|
-
{ ...VALID_BODY, successUrl: "https://evil.test/x" },
|
|
1588
|
-
headers,
|
|
1589
|
-
);
|
|
1590
|
-
|
|
1591
|
-
expect(res.statusCode).toBe(400);
|
|
1592
|
-
expect(prisma.session.findUnique).not.toHaveBeenCalled();
|
|
1593
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1594
|
-
});
|
|
1595
|
-
});
|
|
1596
|
-
|
|
1597
|
-
// ── Trusted-origin allowlist (#7395) ──────────────────────────────────────────
|
|
1598
|
-
|
|
1599
|
-
describe("POST /v1/checkout/sessions — trusted-origin allowlist (#7395)", () => {
|
|
1600
|
-
const SITE_ORIGIN = "https://example.com";
|
|
1601
|
-
const SPOKE_A = "https://spoke-a.example";
|
|
1602
|
-
const SESSION_TOKEN = "sess_tok_7395";
|
|
1603
|
-
const COOKIE = `better-auth.session_token=${SESSION_TOKEN}.signature`;
|
|
1604
|
-
const originalDatabaseUrl = process.env.DATABASE_URL;
|
|
1605
|
-
|
|
1606
|
-
let app: FastifyInstance;
|
|
1607
|
-
|
|
1608
|
-
function post(
|
|
1609
|
-
payload: Record<string, unknown>,
|
|
1610
|
-
headers: Record<string, string> = {},
|
|
1611
|
-
) {
|
|
1612
|
-
return app.inject({
|
|
1613
|
-
method: "POST",
|
|
1614
|
-
url: "/v1/checkout/sessions",
|
|
1615
|
-
headers,
|
|
1616
|
-
payload,
|
|
1617
|
-
});
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
beforeEach(async () => {
|
|
1621
|
-
jest.clearAllMocks();
|
|
1622
|
-
config.TRUSTED_ORIGINS = [SITE_ORIGIN, SPOKE_A];
|
|
1623
|
-
process.env["STRIPE_SECRET_KEY"] = "sk_test_route_fixture";
|
|
1624
|
-
delete process.env["OSS_API_INTERNAL_SECRET"];
|
|
1625
|
-
mockAssertSandboxKey.mockImplementation(() => undefined);
|
|
1626
|
-
mockCreateCheckoutSession.mockResolvedValue({
|
|
1627
|
-
sessionId: "cs_test_allow7395",
|
|
1628
|
-
url: "https://checkout.stripe.com/c/pay/cs_test_allow7395",
|
|
1629
|
-
});
|
|
1630
|
-
|
|
1631
|
-
app = buildApp();
|
|
1632
|
-
await app.ready();
|
|
1633
|
-
});
|
|
1634
|
-
|
|
1635
|
-
afterEach(async () => {
|
|
1636
|
-
await app.close();
|
|
1637
|
-
delete process.env["STRIPE_SECRET_KEY"];
|
|
1638
|
-
config.TRUSTED_ORIGINS = [SITE_ORIGIN];
|
|
1639
|
-
(prisma.session.findUnique as jest.Mock).mockReset();
|
|
1640
|
-
(prisma.user.findUnique as jest.Mock).mockReset();
|
|
1641
|
-
if (originalDatabaseUrl === undefined) delete process.env.DATABASE_URL;
|
|
1642
|
-
else process.env.DATABASE_URL = originalDatabaseUrl;
|
|
1643
|
-
});
|
|
1644
|
-
|
|
1645
|
-
it("accepts an anonymous checkout with both redirects on a listed spoke", async () => {
|
|
1646
|
-
const successUrl = `${SPOKE_A}/purchase/success`;
|
|
1647
|
-
const cancelUrl = `${SPOKE_A}/purchase/cancel`;
|
|
1648
|
-
|
|
1649
|
-
const res = await post({ productCode: "ailk-pro", successUrl, cancelUrl });
|
|
1650
|
-
|
|
1651
|
-
expect(res.statusCode).toBe(201);
|
|
1652
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1653
|
-
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1654
|
-
{ refuseProduct: isOfferingsProduct },
|
|
1655
|
-
);
|
|
1656
|
-
});
|
|
1657
|
-
|
|
1658
|
-
it("accepts redirects split across two listed origins", async () => {
|
|
1659
|
-
const successUrl = `${SPOKE_A}/purchase/success`;
|
|
1660
|
-
const cancelUrl = `${SITE_ORIGIN}/pricing`;
|
|
1661
|
-
|
|
1662
|
-
const res = await post({ productCode: "ailk-pro", successUrl, cancelUrl });
|
|
1663
|
-
|
|
1664
|
-
expect(res.statusCode).toBe(201);
|
|
1665
|
-
expect(mockCreateCheckoutSession).toHaveBeenCalledWith(
|
|
1666
|
-
expect.objectContaining({ successUrl, cancelUrl }),
|
|
1667
|
-
{ refuseProduct: isOfferingsProduct },
|
|
1668
|
-
);
|
|
1669
|
-
});
|
|
1670
|
-
|
|
1671
|
-
it.each([
|
|
1672
|
-
["successUrl", "https://spoke-b.example/x"],
|
|
1673
|
-
["successUrl", "https://spoke-a.example.evil.test/x"],
|
|
1674
|
-
["cancelUrl", "https://spoke-b.example/x"],
|
|
1675
|
-
["cancelUrl", "https://spoke-a.example.evil.test/x"],
|
|
1676
|
-
])(
|
|
1677
|
-
"refuses a %s on the unlisted %j with 400 naming the field and no Stripe call",
|
|
1678
|
-
async (field, url) => {
|
|
1679
|
-
const res = await post({ ...VALID_BODY, [field]: url });
|
|
1680
|
-
|
|
1681
|
-
expect(res.statusCode).toBe(400);
|
|
1682
|
-
const body = res.json();
|
|
1683
|
-
expect(body.error).toBe("validation-error");
|
|
1684
|
-
expect(body.details).toHaveLength(1);
|
|
1685
|
-
expect(body.details[0].path).toEqual([field]);
|
|
1686
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1687
|
-
},
|
|
1688
|
-
);
|
|
1689
|
-
|
|
1690
|
-
it("answers 500 config-error with no Stripe call when the list is empty", async () => {
|
|
1691
|
-
config.TRUSTED_ORIGINS = [];
|
|
1692
|
-
|
|
1693
|
-
const res = await post({
|
|
1694
|
-
...VALID_BODY,
|
|
1695
|
-
successUrl: `${SPOKE_A}/purchase/success`,
|
|
1696
|
-
});
|
|
1697
|
-
|
|
1698
|
-
expect(res.statusCode).toBe(500);
|
|
1699
|
-
expect(res.json()).toEqual({ error: "config-error" });
|
|
1700
|
-
expect(mockCreateCheckoutSession).not.toHaveBeenCalled();
|
|
1701
|
-
});
|
|
1702
|
-
|
|
1703
|
-
it("signs the buyer in for a valid cookie forwarded with a listed spoke's Origin", async () => {
|
|
1704
|
-
process.env.DATABASE_URL = "postgresql://test:test@localhost/test";
|
|
1705
|
-
(prisma.session.findUnique as jest.Mock).mockResolvedValue({
|
|
1706
|
-
id: "ses_1",
|
|
1707
|
-
userId: "usr_buyer",
|
|
1708
|
-
expiresAt: new Date(Date.now() + 60_000),
|
|
1709
|
-
activeOrganizationId: null,
|
|
1710
|
-
activeWorkspaceId: null,
|
|
1711
|
-
});
|
|
1712
|
-
(prisma.user.findUnique as jest.Mock).mockImplementation(
|
|
1713
|
-
async (args: { select: Record<string, boolean> }) => {
|
|
1714
|
-
const row: Record<string, unknown> = {
|
|
1715
|
-
id: "usr_buyer",
|
|
1716
|
-
email: "buyer@example.com",
|
|
1717
|
-
emailVerified: true,
|
|
1718
|
-
billingProvider: null,
|
|
1719
|
-
billingCustomerId: null,
|
|
1720
|
-
};
|
|
1721
|
-
return Object.fromEntries(
|
|
1722
|
-
Object.keys(args.select).map((k) => [k, row[k]]),
|
|
1723
|
-
);
|
|
1724
|
-
},
|
|
1725
|
-
);
|
|
1726
|
-
|
|
1727
|
-
const res = await post(
|
|
1728
|
-
{
|
|
1729
|
-
productCode: "ailk-pro",
|
|
1730
|
-
successUrl: `${SPOKE_A}/purchase/success`,
|
|
1731
|
-
cancelUrl: `${SPOKE_A}/purchase/cancel`,
|
|
1732
|
-
},
|
|
1733
|
-
{ cookie: COOKIE, origin: SPOKE_A },
|
|
1734
|
-
);
|
|
1735
|
-
|
|
1736
|
-
expect(res.statusCode).toBe(201);
|
|
1737
|
-
expect(mockCreateCheckoutSession.mock.calls[0][0].buyer).toEqual({
|
|
1738
|
-
userId: "usr_buyer",
|
|
1739
|
-
email: "buyer@example.com",
|
|
1740
|
-
});
|
|
1741
|
-
});
|
|
1742
|
-
});
|