create-ailk 0.24.0 → 0.25.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -20
- 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 +1 -1
- package/dist/lib/fetch-template-snapshot.js +7 -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 +34 -12
- package/dist/module-architecture.d.ts +20 -3
- package/dist/module-architecture.js +117 -550
- package/dist/paid-sections.d.ts +25 -1
- package/dist/paid-sections.js +88 -3
- package/dist/surfaces.js +10 -2
- package/dist/template-record.d.ts +14 -4
- package/dist/template-record.js +23 -5
- package/dist/topology.d.ts +16 -3
- package/dist/topology.js +31 -19
- package/dist/update.d.ts +10 -1
- package/dist/update.js +540 -16
- package/package.json +3 -3
- package/release-scope.json +1 -1
- 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 +5 -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 +2 -2
- package/templates/apps/api/CLAUDE.md +102 -76
- 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.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 +67 -20
- 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 +10 -3
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +9 -3
- 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]/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 +2 -2
- package/templates/apps/web/jest.config.cjs +1 -0
- package/templates/apps/web/lib/auth-email.ts +1 -1
- 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/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 +48 -35
- 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,1083 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* An Organization's Stripe connection (#8876, spec
|
|
3
|
-
* docs/specs/8876-the-offerings-data-model-typed-spec.md D6; #8907, spec
|
|
4
|
-
* docs/specs/8907-the-organization-stripe-connection-custody-spec.md D2–D6,
|
|
5
|
-
* D11–D13; contract docs/specs/8879-the-offerings-security-contract-key-spec.md
|
|
6
|
-
* C1–C11, K3–K9, P2) — the ONE file that reads or writes the StripeConnection
|
|
7
|
-
* model, and the one that encrypts or decrypts its key and webhook secret.
|
|
8
|
-
*
|
|
9
|
-
* WRITE-ONLY TO EVERY READER. No function here returns the key, the webhook
|
|
10
|
-
* secret, the routing token's row or a ciphertext to a route: the summary
|
|
11
|
-
* and the view select no ciphertext (the view reads only whether a secret is
|
|
12
|
-
* set), and a write answers `Saved`, which carries no `last4` and no token
|
|
13
|
-
* (SEC-8907-10). Only `stripeClientForOrganization` and the token lookup
|
|
14
|
-
* decrypt, for their server-side callers (#8054, #8861).
|
|
15
|
-
*
|
|
16
|
-
* ENCRYPTION AT REST (D2, C2, C10). `v1.<iv>.<tag>.<ciphertext>`, each part
|
|
17
|
-
* base64url: AES-256-GCM under `config.STRIPE_CONNECTION_KEY`, a fresh 12-byte
|
|
18
|
-
* IV, a 16-byte tag (enforced on decrypt), and
|
|
19
|
-
* `stripe-connection:v1:<organizationId>:<field>` as additional data, so a
|
|
20
|
-
* value copied to another Organization, into the other column or under the
|
|
21
|
-
* other mode fails authentication. Decryption tries the current key, then
|
|
22
|
-
* `STRIPE_CONNECTION_KEY_PREVIOUS`; encryption always uses the current key.
|
|
23
|
-
* A copy of the scheduling envelope, not an import: that file is a slice file
|
|
24
|
-
* and this one is base tree (D2).
|
|
25
|
-
*
|
|
26
|
-
* DATA ACCESS (D6, C11). Ten delegate calls, each keyed on an
|
|
27
|
-
* `organizationId` the server resolved (the tenant, or the row a token
|
|
28
|
-
* named), pinned by `architecture.yaml`'s `paths_scoped` count. Only the
|
|
29
|
-
* rekey walk lists rows, and only the bin calls it. Every call is wrapped so
|
|
30
|
-
* a Prisma error leaves as a `StripeConnectionStoreError` with a fixed
|
|
31
|
-
* message and no cause — a CHECK violation's text names the row's values
|
|
32
|
-
* (SEC-8907-9).
|
|
33
|
-
*
|
|
34
|
-
* `null` means no row and nothing else. A row that fails its schema is
|
|
35
|
-
* `"unreadable"`, so no caller mistakes a corrupt connection for an absent
|
|
36
|
-
* one and falls back to the deployment's env key (contract A2, NFR2).
|
|
37
|
-
*
|
|
38
|
-
* LOGGING (D15, C5). Only `organizationId`, `mode`, a fixed `outcome` or
|
|
39
|
-
* `reason`, and an error's class name. Never the key or any part of it,
|
|
40
|
-
* `last4`, the secret, the token, a ciphertext or Stripe error text.
|
|
41
|
-
*/
|
|
42
|
-
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
43
|
-
|
|
44
|
-
import { logger } from "@working-theory/observability";
|
|
45
|
-
import {
|
|
46
|
-
stripeConnectionSummarySchema,
|
|
47
|
-
type StripeConnectionSummary,
|
|
48
|
-
} from "@working-theory/validation";
|
|
49
|
-
import { z } from "zod";
|
|
50
|
-
|
|
51
|
-
import { config } from "../../config/index.js";
|
|
52
|
-
import { isModuleEnabled } from "../../config/modules.js";
|
|
53
|
-
import { prisma } from "../../lib/prisma.js";
|
|
54
|
-
import {
|
|
55
|
-
classifyStripeKey,
|
|
56
|
-
getStripeClient,
|
|
57
|
-
isProductionBuild,
|
|
58
|
-
stripeClientForConnection,
|
|
59
|
-
type StripeConnectionMode,
|
|
60
|
-
type StripeKeyClass,
|
|
61
|
-
} from "../../lib/stripe.js";
|
|
62
|
-
|
|
63
|
-
/** The connection's summary, `"unreadable"` for a corrupt row, `null` for none. */
|
|
64
|
-
export async function getStripeConnectionSummary(
|
|
65
|
-
organizationId: string,
|
|
66
|
-
): Promise<StripeConnectionSummary | "unreadable" | null> {
|
|
67
|
-
const row = await store(() =>
|
|
68
|
-
prisma.stripeConnection.findUnique({
|
|
69
|
-
where: { organizationId },
|
|
70
|
-
select: { mode: true, keyLast4: true, liveAt: true, createdAt: true, updatedAt: true },
|
|
71
|
-
}),
|
|
72
|
-
);
|
|
73
|
-
if (row === null) return null;
|
|
74
|
-
const summary = stripeConnectionSummarySchema.safeParse({
|
|
75
|
-
mode: row.mode,
|
|
76
|
-
last4: row.keyLast4,
|
|
77
|
-
connectedAt: isoOf(row.createdAt),
|
|
78
|
-
liveAt: row.liveAt === null ? null : isoOf(row.liveAt),
|
|
79
|
-
updatedAt: isoOf(row.updatedAt),
|
|
80
|
-
});
|
|
81
|
-
if (summary.success) return summary.data;
|
|
82
|
-
logUnreadableRow(organizationId);
|
|
83
|
-
return "unreadable";
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/** An ISO string, or `null` for a value that is not a valid Date. */
|
|
87
|
-
function isoOf(value: unknown): string | null {
|
|
88
|
-
return value instanceof Date && !Number.isNaN(value.getTime()) ? value.toISOString() : null;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
function logUnreadableRow(organizationId: string): void {
|
|
92
|
-
logger.error(
|
|
93
|
-
{ organizationId, reason: "connection_unreadable" },
|
|
94
|
-
"[offerings] stripe connection row is unreadable",
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function logUnreadableValue(organizationId: string): void {
|
|
99
|
-
logger.error(
|
|
100
|
-
{ organizationId, reason: "credential_unreadable" },
|
|
101
|
-
"[offerings] stripe connection value could not be decrypted",
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// ─── The store boundary ─────────────────────────────────────────────────────
|
|
106
|
-
|
|
107
|
-
/** Every store failure, with a fixed message and no cause (SEC-8907-9). */
|
|
108
|
-
export class StripeConnectionStoreError extends Error {
|
|
109
|
-
readonly uniqueViolation: boolean;
|
|
110
|
-
constructor(uniqueViolation: boolean) {
|
|
111
|
-
super("stripe connection store call failed");
|
|
112
|
-
this.name = "StripeConnectionStoreError";
|
|
113
|
-
this.uniqueViolation = uniqueViolation;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function store<T>(call: () => Promise<T>): Promise<T> {
|
|
118
|
-
try {
|
|
119
|
-
return await call();
|
|
120
|
-
} catch (err) {
|
|
121
|
-
const code = (err as { code?: unknown } | null)?.code;
|
|
122
|
-
throw new StripeConnectionStoreError(code === "P2002");
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// ─── The cipher (D2) ────────────────────────────────────────────────────────
|
|
127
|
-
|
|
128
|
-
/** The three encrypted values; each binds its own additional data. */
|
|
129
|
-
export type StripeConnectionField = "credential" | "webhook-secret:test" | "webhook-secret:live";
|
|
130
|
-
|
|
131
|
-
const FORMAT_VERSION = "v1";
|
|
132
|
-
const IV_BYTES = 12;
|
|
133
|
-
const TAG_BYTES = 16;
|
|
134
|
-
const KEY_BYTES = 32;
|
|
135
|
-
|
|
136
|
-
function additionalData(organizationId: string, field: StripeConnectionField): Buffer {
|
|
137
|
-
return Buffer.from(`stripe-connection:${FORMAT_VERSION}:${organizationId}:${field}`, "utf8");
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function secretField(mode: StripeConnectionMode): StripeConnectionField {
|
|
141
|
-
return mode === "live" ? "webhook-secret:live" : "webhook-secret:test";
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Encrypt one value for one Organization's field (D2). */
|
|
145
|
-
export function encryptStripeConnectionValue(
|
|
146
|
-
plaintext: string,
|
|
147
|
-
organizationId: string,
|
|
148
|
-
field: StripeConnectionField,
|
|
149
|
-
key: Buffer,
|
|
150
|
-
): string {
|
|
151
|
-
const iv = randomBytes(IV_BYTES);
|
|
152
|
-
const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: TAG_BYTES });
|
|
153
|
-
cipher.setAAD(additionalData(organizationId, field));
|
|
154
|
-
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
155
|
-
return [
|
|
156
|
-
FORMAT_VERSION,
|
|
157
|
-
iv.toString("base64url"),
|
|
158
|
-
cipher.getAuthTag().toString("base64url"),
|
|
159
|
-
ciphertext.toString("base64url"),
|
|
160
|
-
].join(".");
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** One key's attempt: `null` for any failure, never a throw. */
|
|
164
|
-
function decryptUnder(
|
|
165
|
-
stored: string,
|
|
166
|
-
organizationId: string,
|
|
167
|
-
field: StripeConnectionField,
|
|
168
|
-
key: Buffer | undefined,
|
|
169
|
-
): string | null {
|
|
170
|
-
if (!key || key.length !== KEY_BYTES) return null;
|
|
171
|
-
const parts = stored.split(".");
|
|
172
|
-
if (parts.length !== 4 || parts[0] !== FORMAT_VERSION) return null;
|
|
173
|
-
const iv = Buffer.from(parts[1] ?? "", "base64url");
|
|
174
|
-
const tag = Buffer.from(parts[2] ?? "", "base64url");
|
|
175
|
-
const ciphertext = Buffer.from(parts[3] ?? "", "base64url");
|
|
176
|
-
if (iv.length !== IV_BYTES || tag.length !== TAG_BYTES) return null;
|
|
177
|
-
try {
|
|
178
|
-
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: TAG_BYTES });
|
|
179
|
-
decipher.setAAD(additionalData(organizationId, field));
|
|
180
|
-
decipher.setAuthTag(tag);
|
|
181
|
-
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
|
182
|
-
} catch {
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
/**
|
|
188
|
-
* Decrypt one value under the current key, then the previous one (C10), or
|
|
189
|
-
* `null` when it is unreadable for ANY reason — no key, a wrong key, another
|
|
190
|
-
* Organization's value, the other field or mode, a part of the wrong length.
|
|
191
|
-
*/
|
|
192
|
-
export function decryptStripeConnectionValue(
|
|
193
|
-
stored: string,
|
|
194
|
-
organizationId: string,
|
|
195
|
-
field: StripeConnectionField,
|
|
196
|
-
): string | null {
|
|
197
|
-
return (
|
|
198
|
-
decryptUnder(stored, organizationId, field, config.STRIPE_CONNECTION_KEY) ??
|
|
199
|
-
decryptUnder(stored, organizationId, field, config.STRIPE_CONNECTION_KEY_PREVIOUS)
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
/** Whether the writes can run: `STRIPE_CONNECTION_KEY` is set (C3). */
|
|
204
|
-
export function stripeConnectionKeyConfigured(): boolean {
|
|
205
|
-
return config.STRIPE_CONNECTION_KEY !== undefined;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** A routing token: 256 random bits, 43 base64url characters (W1). */
|
|
209
|
-
function mintWebhookToken(): string {
|
|
210
|
-
return randomBytes(32).toString("base64url");
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const WEBHOOK_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
214
|
-
const LAST4_RE = /^[A-Za-z0-9]{4}$/;
|
|
215
|
-
const MODES = new Set<unknown>(["test", "live"]);
|
|
216
|
-
|
|
217
|
-
function isMode(value: unknown): value is StripeConnectionMode {
|
|
218
|
-
return MODES.has(value);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// ─── Admission by class (D4) ────────────────────────────────────────────────
|
|
222
|
-
|
|
223
|
-
export type StripeKeyRefusalReason =
|
|
224
|
-
| "key_not_live"
|
|
225
|
-
| "key_unrestricted"
|
|
226
|
-
| "live_key_needs_go_live"
|
|
227
|
-
| "key_publishable"
|
|
228
|
-
| "key_unrecognized"
|
|
229
|
-
| "key_not_usable";
|
|
230
|
-
|
|
231
|
-
export type StripeKeyColumn = "connect" | "go-live";
|
|
232
|
-
|
|
233
|
-
type Admission = "admit" | Exclude<StripeKeyRefusalReason, "key_not_usable">;
|
|
234
|
-
|
|
235
|
-
/** One row per class, so a seventh class fails the type check (K3, K4, K5). */
|
|
236
|
-
const ADMISSION: Record<StripeKeyColumn, Record<StripeKeyClass, Admission>> = {
|
|
237
|
-
connect: {
|
|
238
|
-
"restricted-test": "admit",
|
|
239
|
-
"secret-test": "key_unrestricted",
|
|
240
|
-
"restricted-live": "live_key_needs_go_live",
|
|
241
|
-
"secret-live": "key_unrestricted",
|
|
242
|
-
publishable: "key_publishable",
|
|
243
|
-
unknown: "key_unrecognized",
|
|
244
|
-
},
|
|
245
|
-
"go-live": {
|
|
246
|
-
"restricted-test": "key_not_live",
|
|
247
|
-
"secret-test": "key_unrestricted",
|
|
248
|
-
"restricted-live": "admit",
|
|
249
|
-
"secret-live": "key_unrestricted",
|
|
250
|
-
publishable: "key_publishable",
|
|
251
|
-
unknown: "key_unrecognized",
|
|
252
|
-
},
|
|
253
|
-
};
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* The class table's answer for one submitted key (D4). On admit, the key is
|
|
257
|
-
* `raw.trim()` — the string the classifier decided on — and `keyLast4` its
|
|
258
|
-
* last four characters; a key whose last four are not alphanumeric (a body
|
|
259
|
-
* under four characters, which no Stripe key has) is `key_not_usable` with no
|
|
260
|
-
* probe, so the database's CHECK is never what refuses.
|
|
261
|
-
*/
|
|
262
|
-
export function admitConnectionKey(
|
|
263
|
-
raw: string,
|
|
264
|
-
column: StripeKeyColumn,
|
|
265
|
-
):
|
|
266
|
-
| { ok: true; key: string; keyLast4: string }
|
|
267
|
-
| { ok: false; reason: StripeKeyRefusalReason } {
|
|
268
|
-
const admission = ADMISSION[column][classifyStripeKey(raw)];
|
|
269
|
-
if (admission !== "admit") return { ok: false, reason: admission };
|
|
270
|
-
const key = raw.trim();
|
|
271
|
-
const keyLast4 = key.slice(-4);
|
|
272
|
-
if (!LAST4_RE.test(keyLast4)) return { ok: false, reason: "key_not_usable" };
|
|
273
|
-
return { ok: true, key, keyLast4 };
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// ─── The probes (D5, K9, P2) ────────────────────────────────────────────────
|
|
277
|
-
|
|
278
|
-
/** The longest the three probes may take together before the answer is `unavailable`. */
|
|
279
|
-
export const PROBE_TIMEOUT_MS = 10_000;
|
|
280
|
-
|
|
281
|
-
export type ProbeOutcome = "usable" | "not_usable" | "unavailable";
|
|
282
|
-
|
|
283
|
-
const NOT_USABLE_TYPES = new Set(["StripeAuthenticationError", "StripePermissionError"]);
|
|
284
|
-
|
|
285
|
-
function refusesTheKey(reason: unknown): boolean {
|
|
286
|
-
const { type, statusCode } = (reason ?? {}) as { type?: unknown; statusCode?: unknown };
|
|
287
|
-
return (
|
|
288
|
-
(typeof type === "string" && NOT_USABLE_TYPES.has(type)) ||
|
|
289
|
-
statusCode === 401 ||
|
|
290
|
-
statusCode === 403
|
|
291
|
-
);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function errNameOf(reason: unknown): string {
|
|
295
|
-
if (reason instanceof Error) return reason.name;
|
|
296
|
-
const type = (reason as { type?: unknown } | null)?.type;
|
|
297
|
-
return typeof type === "string" ? type : "unknown";
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* The three read probes (D5): `products.list`, `prices.list` and
|
|
302
|
-
* `checkout.sessions.list`, each `{ limit: 1 }`, started together and decided
|
|
303
|
-
* from all three settled results, so a refused key costs the same one round
|
|
304
|
-
* whichever probe refused it (SEC-8907-4). One answer covers an invalid key
|
|
305
|
-
* and a valid key missing a permission. The client lives only inside the
|
|
306
|
-
* call (C8).
|
|
307
|
-
*/
|
|
308
|
-
export async function probeStripeKey(input: {
|
|
309
|
-
organizationId: string;
|
|
310
|
-
key: string;
|
|
311
|
-
mode: StripeConnectionMode;
|
|
312
|
-
liveAt: Date | null;
|
|
313
|
-
}): Promise<ProbeOutcome> {
|
|
314
|
-
const { organizationId } = input;
|
|
315
|
-
const client = stripeClientForConnection(input);
|
|
316
|
-
if (client === null) return logProbe(organizationId, "not_usable");
|
|
317
|
-
|
|
318
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
319
|
-
const timedOut = new Promise<"timeout">((resolve) => {
|
|
320
|
-
timer = setTimeout(() => resolve("timeout"), PROBE_TIMEOUT_MS);
|
|
321
|
-
});
|
|
322
|
-
const settled = Promise.allSettled([
|
|
323
|
-
client.products.list({ limit: 1 }),
|
|
324
|
-
client.prices.list({ limit: 1 }),
|
|
325
|
-
client.checkout.sessions.list({ limit: 1 }),
|
|
326
|
-
]);
|
|
327
|
-
const results = await Promise.race([settled, timedOut]).finally(() => clearTimeout(timer));
|
|
328
|
-
if (results === "timeout") return logProbe(organizationId, "unavailable", "timeout");
|
|
329
|
-
|
|
330
|
-
const rejected = results.flatMap((r) => (r.status === "rejected" ? [r.reason as unknown] : []));
|
|
331
|
-
if (rejected.length === 0) return logProbe(organizationId, "usable");
|
|
332
|
-
if (rejected.some(refusesTheKey)) return logProbe(organizationId, "not_usable");
|
|
333
|
-
return logProbe(organizationId, "unavailable", undefined, errNameOf(rejected[0]));
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
function logProbe(
|
|
337
|
-
organizationId: string,
|
|
338
|
-
outcome: ProbeOutcome,
|
|
339
|
-
reason?: "timeout",
|
|
340
|
-
errName?: string,
|
|
341
|
-
): ProbeOutcome {
|
|
342
|
-
const line = {
|
|
343
|
-
organizationId,
|
|
344
|
-
outcome,
|
|
345
|
-
...(reason && { reason }),
|
|
346
|
-
...(errName && { errName }),
|
|
347
|
-
};
|
|
348
|
-
if (outcome === "unavailable") logger.warn(line, "[offerings] stripe key probe");
|
|
349
|
-
else logger.info(line, "[offerings] stripe key probe");
|
|
350
|
-
return outcome;
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// ─── The reads ──────────────────────────────────────────────────────────────
|
|
354
|
-
|
|
355
|
-
/** C4's GET body: no key, no secret, no ciphertext — and no plaintext token outside the path. */
|
|
356
|
-
const viewSchema = z
|
|
357
|
-
.object({
|
|
358
|
-
mode: z.enum(["test", "live"]),
|
|
359
|
-
last4: z.string().regex(LAST4_RE),
|
|
360
|
-
webhookPath: z.string().regex(/^\/v1\/webhooks\/stripe\/[A-Za-z0-9_-]{43}$/),
|
|
361
|
-
webhookSecretSet: z.boolean(),
|
|
362
|
-
connectedAt: z.iso.datetime(),
|
|
363
|
-
liveAt: z.iso.datetime().nullable(),
|
|
364
|
-
updatedAt: z.iso.datetime(),
|
|
365
|
-
})
|
|
366
|
-
.strict()
|
|
367
|
-
.refine((view) => (view.mode === "live") === (view.liveAt !== null), { path: ["liveAt"] });
|
|
368
|
-
export type StripeConnectionView = z.infer<typeof viewSchema>;
|
|
369
|
-
|
|
370
|
-
/**
|
|
371
|
-
* The read-only source the GET answers when the Organization has NO stored
|
|
372
|
-
* connection but OWNS the Stripe key in this API process's environment
|
|
373
|
-
* (#9285 AC-3, AC-8): the deployment's own `STRIPE_SECRET_KEY`. It names the
|
|
374
|
-
* mode the key's class implies and nothing else — never the key, its last
|
|
375
|
-
* four or a webhook path — and a publishable or unrecognised value is no
|
|
376
|
-
* source at all. A stored connection always wins; the caller decides that,
|
|
377
|
-
* not this function.
|
|
378
|
-
*/
|
|
379
|
-
export type StripeEnvironmentSource = { source: "environment"; mode: StripeConnectionMode };
|
|
380
|
-
|
|
381
|
-
/** The mode the process's own Stripe key implies, or `null` for no usable class. */
|
|
382
|
-
function environmentKeyMode(): StripeConnectionMode | null {
|
|
383
|
-
switch (classifyStripeKey(process.env["STRIPE_SECRET_KEY"])) {
|
|
384
|
-
case "restricted-test":
|
|
385
|
-
case "secret-test":
|
|
386
|
-
return "test";
|
|
387
|
-
case "restricted-live":
|
|
388
|
-
case "secret-live":
|
|
389
|
-
return "live";
|
|
390
|
-
default:
|
|
391
|
-
return null;
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Whether this Organization owns the environment key (#9285 AC-8). Without
|
|
397
|
-
* the multi-tenant module a deployment has one Organization, and it owns the
|
|
398
|
-
* key. With it, only the Organization named by `STRIPE_ENV_ORGANIZATION_ID`
|
|
399
|
-
* does; unset, blank or naming no Organization, nobody does (fail closed), so
|
|
400
|
-
* one tenant's Offerings can never sell through another's account.
|
|
401
|
-
*/
|
|
402
|
-
export function organizationOwnsEnvironmentStripeKey(organizationId: string): boolean {
|
|
403
|
-
if (!isModuleEnabled("multi-tenant")) return true;
|
|
404
|
-
const owner = process.env["STRIPE_ENV_ORGANIZATION_ID"]?.trim();
|
|
405
|
-
return owner !== undefined && owner !== "" && owner === organizationId;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
/** The environment source for this Organization, or `null` when it has none to see. */
|
|
409
|
-
export function getEnvironmentStripeSource(organizationId: string): StripeEnvironmentSource | null {
|
|
410
|
-
if (!organizationOwnsEnvironmentStripeKey(organizationId)) return null;
|
|
411
|
-
const mode = environmentKeyMode();
|
|
412
|
-
return mode === null ? null : { source: "environment", mode };
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
* The connection state Offerings sells on (#9285 AC-9): the stored summary
|
|
417
|
-
* first, else `"environment"` for an Organization that owns the environment
|
|
418
|
-
* key, else `null`. `"unreadable"` stays a corrupt stored row. The one
|
|
419
|
-
* "is this Organization connected" read every Offerings path shares.
|
|
420
|
-
*/
|
|
421
|
-
export async function getOfferingsConnectionState(
|
|
422
|
-
organizationId: string,
|
|
423
|
-
): Promise<StripeConnectionSummary | "unreadable" | "environment" | null> {
|
|
424
|
-
const stored = await getStripeConnectionSummary(organizationId);
|
|
425
|
-
if (stored !== null) return stored;
|
|
426
|
-
return getEnvironmentStripeSource(organizationId) === null ? null : "environment";
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/** What a write answers (D7): no `last4`, no `webhookPath` (SEC-8907-10). */
|
|
430
|
-
export type StripeConnectionSaved = {
|
|
431
|
-
mode: StripeConnectionMode;
|
|
432
|
-
webhookSecretSet: boolean;
|
|
433
|
-
liveAt: string | null;
|
|
434
|
-
updatedAt: string;
|
|
435
|
-
};
|
|
436
|
-
|
|
437
|
-
/**
|
|
438
|
-
* C4's GET (D7): the view, `"unreadable"` for a row that fails its schema,
|
|
439
|
-
* `null` for none. `webhookSecretSet` is decided from presence, never by
|
|
440
|
-
* decrypting.
|
|
441
|
-
*/
|
|
442
|
-
export async function getStripeConnectionView(
|
|
443
|
-
organizationId: string,
|
|
444
|
-
): Promise<StripeConnectionView | "unreadable" | null> {
|
|
445
|
-
const row = await store(() =>
|
|
446
|
-
prisma.stripeConnection.findUnique({
|
|
447
|
-
where: { organizationId },
|
|
448
|
-
select: {
|
|
449
|
-
mode: true,
|
|
450
|
-
keyLast4: true,
|
|
451
|
-
webhookToken: true,
|
|
452
|
-
webhookSecretCiphertext: true,
|
|
453
|
-
liveAt: true,
|
|
454
|
-
createdAt: true,
|
|
455
|
-
updatedAt: true,
|
|
456
|
-
},
|
|
457
|
-
}),
|
|
458
|
-
);
|
|
459
|
-
if (row === null) return null;
|
|
460
|
-
const view = viewSchema.safeParse({
|
|
461
|
-
mode: row.mode,
|
|
462
|
-
last4: row.keyLast4,
|
|
463
|
-
webhookPath: `/v1/webhooks/stripe/${row.webhookToken}`,
|
|
464
|
-
webhookSecretSet: row.webhookSecretCiphertext !== null,
|
|
465
|
-
connectedAt: isoOf(row.createdAt),
|
|
466
|
-
liveAt: row.liveAt === null ? null : isoOf(row.liveAt),
|
|
467
|
-
updatedAt: isoOf(row.updatedAt),
|
|
468
|
-
});
|
|
469
|
-
if (view.success) return view.data;
|
|
470
|
-
logUnreadableRow(organizationId);
|
|
471
|
-
return "unreadable";
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/** The row every write starts from (D6 call 3, D8 step 12). */
|
|
475
|
-
export type StripeConnectionWriteRow = {
|
|
476
|
-
mode: StripeConnectionMode;
|
|
477
|
-
keyLast4: string;
|
|
478
|
-
webhookSecretSet: boolean;
|
|
479
|
-
liveAt: Date | null;
|
|
480
|
-
updatedAt: Date;
|
|
481
|
-
};
|
|
482
|
-
|
|
483
|
-
/**
|
|
484
|
-
* The read every write starts with (D6 call 3): the mode, last four and
|
|
485
|
-
* `updatedAt` the compare-and-swap keys on, plus whether a secret is set and
|
|
486
|
-
* the go-live time, so a write can answer `Saved` without a second read. The
|
|
487
|
-
* secret's presence is read, never its value.
|
|
488
|
-
*/
|
|
489
|
-
export async function readConnectionForWrite(
|
|
490
|
-
organizationId: string,
|
|
491
|
-
): Promise<StripeConnectionWriteRow | "unreadable" | null> {
|
|
492
|
-
const row = await store(() =>
|
|
493
|
-
prisma.stripeConnection.findUnique({
|
|
494
|
-
where: { organizationId },
|
|
495
|
-
select: {
|
|
496
|
-
mode: true,
|
|
497
|
-
keyLast4: true,
|
|
498
|
-
webhookSecretCiphertext: true,
|
|
499
|
-
liveAt: true,
|
|
500
|
-
updatedAt: true,
|
|
501
|
-
},
|
|
502
|
-
}),
|
|
503
|
-
);
|
|
504
|
-
if (row === null) return null;
|
|
505
|
-
if (!isMode(row.mode) || !LAST4_RE.test(row.keyLast4)) {
|
|
506
|
-
logUnreadableRow(organizationId);
|
|
507
|
-
return "unreadable";
|
|
508
|
-
}
|
|
509
|
-
return {
|
|
510
|
-
mode: row.mode,
|
|
511
|
-
keyLast4: row.keyLast4,
|
|
512
|
-
webhookSecretSet: row.webhookSecretCiphertext !== null,
|
|
513
|
-
liveAt: row.liveAt,
|
|
514
|
-
updatedAt: row.updatedAt,
|
|
515
|
-
};
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
function savedOf(
|
|
519
|
-
mode: StripeConnectionMode,
|
|
520
|
-
webhookSecretSet: boolean,
|
|
521
|
-
liveAt: Date | null,
|
|
522
|
-
updatedAt: Date,
|
|
523
|
-
): StripeConnectionSaved {
|
|
524
|
-
return {
|
|
525
|
-
mode,
|
|
526
|
-
webhookSecretSet,
|
|
527
|
-
liveAt: liveAt === null ? null : liveAt.toISOString(),
|
|
528
|
-
updatedAt: updatedAt.toISOString(),
|
|
529
|
-
};
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
// ─── The writes (D6) ────────────────────────────────────────────────────────
|
|
533
|
-
|
|
534
|
-
type StoredFields = {
|
|
535
|
-
mode?: StripeConnectionMode;
|
|
536
|
-
keyLast4?: string;
|
|
537
|
-
credentialCiphertext?: string;
|
|
538
|
-
webhookSecretCiphertext?: string | null;
|
|
539
|
-
liveAt?: Date | null;
|
|
540
|
-
updatedAt: Date;
|
|
541
|
-
};
|
|
542
|
-
|
|
543
|
-
/**
|
|
544
|
-
* The compare-and-swap replace, go-live, secret set and rekey share (D6 call
|
|
545
|
-
* 5): it writes only when the row still carries the `updatedAt` its caller
|
|
546
|
-
* read, so a write in between is never lost or overwritten.
|
|
547
|
-
*/
|
|
548
|
-
async function writeIfUnchanged(
|
|
549
|
-
organizationId: string,
|
|
550
|
-
updatedAt: Date,
|
|
551
|
-
data: StoredFields,
|
|
552
|
-
): Promise<boolean> {
|
|
553
|
-
const { count } = await store(() =>
|
|
554
|
-
prisma.stripeConnection.updateMany({ where: { organizationId, updatedAt }, data }),
|
|
555
|
-
);
|
|
556
|
-
return count > 0;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
/** A write that did not happen, and why — the route maps each to its answer. */
|
|
560
|
-
export type StripeConnectionWriteRefusal = {
|
|
561
|
-
kind: "connection_changed" | "connection_required" | "key_not_configured" | "live_mode_unavailable";
|
|
562
|
-
};
|
|
563
|
-
|
|
564
|
-
/** What the audit row and the owners' email name: the row as this write left it (C7). */
|
|
565
|
-
export type StripeConnectionAudit = { mode: StripeConnectionMode; last4: string };
|
|
566
|
-
|
|
567
|
-
export type StripeConnectionWrite<Kind extends string> =
|
|
568
|
-
| { kind: Kind; saved: StripeConnectionSaved; audit: StripeConnectionAudit }
|
|
569
|
-
| StripeConnectionWriteRefusal;
|
|
570
|
-
|
|
571
|
-
const CHANGED = { kind: "connection_changed" } as const;
|
|
572
|
-
const REQUIRED = { kind: "connection_required" } as const;
|
|
573
|
-
const NO_KEY = { kind: "key_not_configured" } as const;
|
|
574
|
-
const LIVE_UNAVAILABLE = { kind: "live_mode_unavailable" } as const;
|
|
575
|
-
|
|
576
|
-
/**
|
|
577
|
-
* Re-read for a retry, re-applying every precondition the route's first read
|
|
578
|
-
* enforced: a row that became unreadable is a change, and a row that went
|
|
579
|
-
* live in between is never written from a build that is not production (D3,
|
|
580
|
-
* SEC-8907-3) — a preview sharing production's database must not reach it
|
|
581
|
-
* through the retry either.
|
|
582
|
-
*/
|
|
583
|
-
async function reread(
|
|
584
|
-
organizationId: string,
|
|
585
|
-
): Promise<StripeConnectionWriteRow | null | "changed" | "live"> {
|
|
586
|
-
const row = await readConnectionForWrite(organizationId);
|
|
587
|
-
if (row === "unreadable") return "changed";
|
|
588
|
-
if (row?.mode === "live" && !isProductionBuild()) return "live";
|
|
589
|
-
return row;
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/**
|
|
593
|
-
* A retry acts only on a row in the mode the request was made against: a
|
|
594
|
-
* replace never silently undoes a go-live that committed while its probe
|
|
595
|
-
* ran, and a secret entered for one mode is never bound to the other
|
|
596
|
-
* (C9, W2, SEC-8879-3).
|
|
597
|
-
*/
|
|
598
|
-
function modeMoved(
|
|
599
|
-
row: StripeConnectionWriteRow,
|
|
600
|
-
observed: StripeConnectionWriteRow | null,
|
|
601
|
-
): boolean {
|
|
602
|
-
return row.mode !== (observed?.mode ?? "test");
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* Connect or replace (C9). `observed` is the route's D8 step 12 read: only a
|
|
607
|
-
* request that found no row there may create, so a replace whose row a
|
|
608
|
-
* disconnect removed in between answers `connection_changed` and never brings
|
|
609
|
-
* the connection back (SEC-8907-6). A replace keeps the token, sets the mode
|
|
610
|
-
* to test and clears the secret when the row was live — the secret is bound
|
|
611
|
-
* to the mode. One retry from a fresh read, then `connection_changed`. Two
|
|
612
|
-
* first connects that race are last-writer-wins: the loser's create collides,
|
|
613
|
-
* its retry finds the winner's row and replaces it, and both writes are
|
|
614
|
-
* audited and emailed (D6, "retried once from the read").
|
|
615
|
-
*/
|
|
616
|
-
export async function putStripeConnectionKey(
|
|
617
|
-
organizationId: string,
|
|
618
|
-
key: string,
|
|
619
|
-
keyLast4: string,
|
|
620
|
-
observed: StripeConnectionWriteRow | null,
|
|
621
|
-
): Promise<StripeConnectionWrite<"connect" | "replace">> {
|
|
622
|
-
const cipherKey = config.STRIPE_CONNECTION_KEY;
|
|
623
|
-
if (!cipherKey) return NO_KEY;
|
|
624
|
-
const credentialCiphertext = encryptStripeConnectionValue(key, organizationId, "credential", cipherKey);
|
|
625
|
-
const mayCreate = observed === null;
|
|
626
|
-
|
|
627
|
-
let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
|
|
628
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
629
|
-
if (attempt > 0) row = await reread(organizationId);
|
|
630
|
-
if (row === "changed") return CHANGED;
|
|
631
|
-
if (row === "live") return LIVE_UNAVAILABLE;
|
|
632
|
-
const now = new Date();
|
|
633
|
-
if (row === null) {
|
|
634
|
-
if (!mayCreate) return CHANGED;
|
|
635
|
-
try {
|
|
636
|
-
await store(() =>
|
|
637
|
-
prisma.stripeConnection.create({
|
|
638
|
-
data: {
|
|
639
|
-
organizationId,
|
|
640
|
-
mode: "test",
|
|
641
|
-
keyLast4,
|
|
642
|
-
credentialCiphertext,
|
|
643
|
-
webhookToken: mintWebhookToken(),
|
|
644
|
-
webhookSecretCiphertext: null,
|
|
645
|
-
liveAt: null,
|
|
646
|
-
updatedAt: now,
|
|
647
|
-
},
|
|
648
|
-
select: { id: true },
|
|
649
|
-
}),
|
|
650
|
-
);
|
|
651
|
-
return {
|
|
652
|
-
kind: "connect",
|
|
653
|
-
saved: savedOf("test", false, null, now),
|
|
654
|
-
audit: { mode: "test", last4: keyLast4 },
|
|
655
|
-
};
|
|
656
|
-
} catch (err) {
|
|
657
|
-
if (err instanceof StripeConnectionStoreError && err.uniqueViolation) continue;
|
|
658
|
-
throw err;
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
if (attempt > 0 && modeMoved(row, observed)) return CHANGED;
|
|
662
|
-
const clearsSecret = row.mode === "live";
|
|
663
|
-
const written = await writeIfUnchanged(organizationId, row.updatedAt, {
|
|
664
|
-
mode: "test",
|
|
665
|
-
keyLast4,
|
|
666
|
-
credentialCiphertext,
|
|
667
|
-
liveAt: null,
|
|
668
|
-
...(clearsSecret && { webhookSecretCiphertext: null }),
|
|
669
|
-
updatedAt: now,
|
|
670
|
-
});
|
|
671
|
-
if (written) {
|
|
672
|
-
return {
|
|
673
|
-
kind: "replace",
|
|
674
|
-
saved: savedOf("test", clearsSecret ? false : row.webhookSecretSet, null, now),
|
|
675
|
-
audit: { mode: "test", last4: keyLast4 },
|
|
676
|
-
};
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
return CHANGED;
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
/**
|
|
683
|
-
* Go live (K5): the restricted live key, the mode, the go-live time and the
|
|
684
|
-
* secret bound to `webhook-secret:live`, in one compare-and-swap. A row
|
|
685
|
-
* already live takes the same write, which is how a live key is rotated
|
|
686
|
-
* without returning to test.
|
|
687
|
-
*/
|
|
688
|
-
export async function goLiveStripeConnection(
|
|
689
|
-
organizationId: string,
|
|
690
|
-
key: string,
|
|
691
|
-
keyLast4: string,
|
|
692
|
-
webhookSecret: string,
|
|
693
|
-
now: Date,
|
|
694
|
-
observed: StripeConnectionWriteRow | null,
|
|
695
|
-
): Promise<StripeConnectionWrite<"go_live">> {
|
|
696
|
-
const cipherKey = config.STRIPE_CONNECTION_KEY;
|
|
697
|
-
if (!cipherKey) return NO_KEY;
|
|
698
|
-
if (observed === null) return REQUIRED;
|
|
699
|
-
const data: StoredFields = {
|
|
700
|
-
mode: "live",
|
|
701
|
-
keyLast4,
|
|
702
|
-
credentialCiphertext: encryptStripeConnectionValue(key, organizationId, "credential", cipherKey),
|
|
703
|
-
webhookSecretCiphertext: encryptStripeConnectionValue(
|
|
704
|
-
webhookSecret,
|
|
705
|
-
organizationId,
|
|
706
|
-
"webhook-secret:live",
|
|
707
|
-
cipherKey,
|
|
708
|
-
),
|
|
709
|
-
liveAt: now,
|
|
710
|
-
updatedAt: now,
|
|
711
|
-
};
|
|
712
|
-
|
|
713
|
-
let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
|
|
714
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
715
|
-
if (attempt > 0) row = await reread(organizationId);
|
|
716
|
-
if (row === null || row === "changed") return CHANGED;
|
|
717
|
-
if (row === "live") return LIVE_UNAVAILABLE;
|
|
718
|
-
// Each attempt writes its own `updatedAt`, so the swap's version key only
|
|
719
|
-
// moves forward even when the probe ran long; `liveAt` is the caller's.
|
|
720
|
-
const updatedAt = new Date();
|
|
721
|
-
if (await writeIfUnchanged(organizationId, row.updatedAt, { ...data, updatedAt })) {
|
|
722
|
-
return {
|
|
723
|
-
kind: "go_live",
|
|
724
|
-
saved: savedOf("live", true, now, updatedAt),
|
|
725
|
-
audit: { mode: "live", last4: keyLast4 },
|
|
726
|
-
};
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
return CHANGED;
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
/**
|
|
733
|
-
* Set the webhook secret, bound to the row's own mode. The compare-and-swap
|
|
734
|
-
* and the retry's mode check mean a mode change in between answers
|
|
735
|
-
* `connection_changed` and never binds the secret to the other mode.
|
|
736
|
-
*/
|
|
737
|
-
export async function setStripeWebhookSecret(
|
|
738
|
-
organizationId: string,
|
|
739
|
-
webhookSecret: string,
|
|
740
|
-
observed: StripeConnectionWriteRow | null,
|
|
741
|
-
): Promise<StripeConnectionWrite<"webhook_secret">> {
|
|
742
|
-
const cipherKey = config.STRIPE_CONNECTION_KEY;
|
|
743
|
-
if (!cipherKey) return NO_KEY;
|
|
744
|
-
if (observed === null) return REQUIRED;
|
|
745
|
-
|
|
746
|
-
let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
|
|
747
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
748
|
-
if (attempt > 0) row = await reread(organizationId);
|
|
749
|
-
if (row === null || row === "changed") return CHANGED;
|
|
750
|
-
if (row === "live") return LIVE_UNAVAILABLE;
|
|
751
|
-
if (modeMoved(row, observed)) return CHANGED;
|
|
752
|
-
const now = new Date();
|
|
753
|
-
const written = await writeIfUnchanged(organizationId, row.updatedAt, {
|
|
754
|
-
webhookSecretCiphertext: encryptStripeConnectionValue(
|
|
755
|
-
webhookSecret,
|
|
756
|
-
organizationId,
|
|
757
|
-
secretField(row.mode),
|
|
758
|
-
cipherKey,
|
|
759
|
-
),
|
|
760
|
-
updatedAt: now,
|
|
761
|
-
});
|
|
762
|
-
if (written) {
|
|
763
|
-
return {
|
|
764
|
-
kind: "webhook_secret",
|
|
765
|
-
saved: savedOf(row.mode, true, row.liveAt, now),
|
|
766
|
-
audit: { mode: row.mode, last4: row.keyLast4 },
|
|
767
|
-
};
|
|
768
|
-
}
|
|
769
|
-
}
|
|
770
|
-
return CHANGED;
|
|
771
|
-
}
|
|
772
|
-
|
|
773
|
-
/**
|
|
774
|
-
* Reset the webhook (D6 call 6): a new token and no secret, so the previous
|
|
775
|
-
* URL is `not-found` from this moment. Needs no deployment key — it only
|
|
776
|
-
* takes exposure away. Keyed on the `updatedAt` the caller read, so the mode
|
|
777
|
-
* it answers and audits is the mode it reset, and a row that went live in
|
|
778
|
-
* between is never reset from a build that is not production (D3); a miss is
|
|
779
|
-
* re-read and retried once. A token collision is retried with a new token.
|
|
780
|
-
*/
|
|
781
|
-
export async function resetStripeWebhook(
|
|
782
|
-
organizationId: string,
|
|
783
|
-
observed: StripeConnectionWriteRow,
|
|
784
|
-
): Promise<StripeConnectionWrite<"webhook_reset">> {
|
|
785
|
-
let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
|
|
786
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
787
|
-
if (attempt > 0) row = await reread(organizationId);
|
|
788
|
-
if (row === null) return REQUIRED;
|
|
789
|
-
if (row === "changed") return CHANGED;
|
|
790
|
-
if (row === "live") return LIVE_UNAVAILABLE;
|
|
791
|
-
const current = row;
|
|
792
|
-
const now = new Date();
|
|
793
|
-
try {
|
|
794
|
-
const { count } = await store(() =>
|
|
795
|
-
prisma.stripeConnection.updateMany({
|
|
796
|
-
where: { organizationId, updatedAt: current.updatedAt },
|
|
797
|
-
data: { webhookToken: mintWebhookToken(), webhookSecretCiphertext: null, updatedAt: now },
|
|
798
|
-
}),
|
|
799
|
-
);
|
|
800
|
-
if (count > 0) {
|
|
801
|
-
return {
|
|
802
|
-
kind: "webhook_reset",
|
|
803
|
-
saved: savedOf(current.mode, false, current.liveAt, now),
|
|
804
|
-
audit: { mode: current.mode, last4: current.keyLast4 },
|
|
805
|
-
};
|
|
806
|
-
}
|
|
807
|
-
} catch (err) {
|
|
808
|
-
if (!(err instanceof StripeConnectionStoreError && err.uniqueViolation)) throw err;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
return CHANGED;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* Disconnect (D6 call 7, C8): the row and its token are gone at once. The
|
|
816
|
-
* Organization's enabled set and its workspaces' shown sets stay. Keyed on
|
|
817
|
-
* the `updatedAt` the caller read, so the `remove` audit row and email name
|
|
818
|
-
* the mode and last four of the row actually deleted, and a row that went
|
|
819
|
-
* live in between is never deleted from a build that is not production (D3);
|
|
820
|
-
* a miss is re-read and retried once.
|
|
821
|
-
*/
|
|
822
|
-
export async function deleteStripeConnection(
|
|
823
|
-
organizationId: string,
|
|
824
|
-
observed: StripeConnectionWriteRow,
|
|
825
|
-
): Promise<
|
|
826
|
-
| { kind: "removed"; audit: StripeConnectionAudit }
|
|
827
|
-
| { kind: "absent" }
|
|
828
|
-
| { kind: "connection_changed" | "live_mode_unavailable" }
|
|
829
|
-
> {
|
|
830
|
-
let row: StripeConnectionWriteRow | null | "changed" | "live" = observed;
|
|
831
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
832
|
-
if (attempt > 0) row = await reread(organizationId);
|
|
833
|
-
if (row === null) return { kind: "absent" };
|
|
834
|
-
if (row === "changed") return CHANGED;
|
|
835
|
-
if (row === "live" || (row.mode === "live" && !isProductionBuild())) return LIVE_UNAVAILABLE;
|
|
836
|
-
const current = row;
|
|
837
|
-
const { count } = await store(() =>
|
|
838
|
-
prisma.stripeConnection.deleteMany({ where: { organizationId, updatedAt: current.updatedAt } }),
|
|
839
|
-
);
|
|
840
|
-
if (count > 0) return { kind: "removed", audit: { mode: current.mode, last4: current.keyLast4 } };
|
|
841
|
-
}
|
|
842
|
-
return CHANGED;
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// ─── The token lookup (D11, W1, W2) ─────────────────────────────────────────
|
|
846
|
-
|
|
847
|
-
export type StripeWebhookConnection =
|
|
848
|
-
| { kind: "not-found" }
|
|
849
|
-
| { kind: "not-configured"; organizationId: string; mode: StripeConnectionMode }
|
|
850
|
-
| { kind: "unreadable"; organizationId: string }
|
|
851
|
-
| { kind: "ok"; organizationId: string; mode: StripeConnectionMode; webhookSecret: string };
|
|
852
|
-
|
|
853
|
-
/**
|
|
854
|
-
* The lookup #8861's receiver calls with the token from its path. A token
|
|
855
|
-
* that is not a minted shape is `not-found` with no read; a deleted row's or
|
|
856
|
-
* a reset row's old token is `not-found` from the moment the write commits.
|
|
857
|
-
* A live row on a non-production build is `unreadable` without decrypting
|
|
858
|
-
* (D3, SEC-8907-3).
|
|
859
|
-
*/
|
|
860
|
-
export async function findStripeConnectionByWebhookToken(
|
|
861
|
-
token: string,
|
|
862
|
-
): Promise<StripeWebhookConnection> {
|
|
863
|
-
if (!WEBHOOK_TOKEN_RE.test(token)) return { kind: "not-found" };
|
|
864
|
-
const row = await store(() =>
|
|
865
|
-
prisma.stripeConnection.findUnique({
|
|
866
|
-
where: { webhookToken: token },
|
|
867
|
-
select: { organizationId: true, mode: true, webhookSecretCiphertext: true },
|
|
868
|
-
}),
|
|
869
|
-
);
|
|
870
|
-
if (row === null) return { kind: "not-found" };
|
|
871
|
-
const { organizationId } = row;
|
|
872
|
-
if (!isMode(row.mode)) {
|
|
873
|
-
logUnreadableRow(organizationId);
|
|
874
|
-
return { kind: "unreadable", organizationId };
|
|
875
|
-
}
|
|
876
|
-
if (row.webhookSecretCiphertext === null) {
|
|
877
|
-
return { kind: "not-configured", organizationId, mode: row.mode };
|
|
878
|
-
}
|
|
879
|
-
if (row.mode === "live" && !isProductionBuild()) return { kind: "unreadable", organizationId };
|
|
880
|
-
const webhookSecret = decryptStripeConnectionValue(
|
|
881
|
-
row.webhookSecretCiphertext,
|
|
882
|
-
organizationId,
|
|
883
|
-
secretField(row.mode),
|
|
884
|
-
);
|
|
885
|
-
if (webhookSecret === null) {
|
|
886
|
-
logUnreadableValue(organizationId);
|
|
887
|
-
return { kind: "unreadable", organizationId };
|
|
888
|
-
}
|
|
889
|
-
return { kind: "ok", organizationId, mode: row.mode, webhookSecret };
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
// ─── The use-time client (D12, K6, K7) ──────────────────────────────────────
|
|
893
|
-
|
|
894
|
-
type ConnectionClient = NonNullable<ReturnType<typeof stripeClientForConnection>>;
|
|
895
|
-
|
|
896
|
-
export type StripeConnectionClient =
|
|
897
|
-
| { kind: "none" }
|
|
898
|
-
| { kind: "unusable" }
|
|
899
|
-
| { kind: "ok"; client: ConnectionClient; mode: StripeConnectionMode };
|
|
900
|
-
|
|
901
|
-
/**
|
|
902
|
-
* The environment key's client for the Organization that owns it (#9285
|
|
903
|
-
* AC-9), through `getStripeClient()` — the allow-list the env checkout path
|
|
904
|
-
* already meets (a test key, or the restricted live key the process admitted
|
|
905
|
-
* at boot). A key it refuses is `unusable`, not `none`: the Organization owns
|
|
906
|
-
* a key it cannot sell through.
|
|
907
|
-
*/
|
|
908
|
-
function environmentClientFor(organizationId: string): StripeConnectionClient {
|
|
909
|
-
const source = getEnvironmentStripeSource(organizationId);
|
|
910
|
-
if (source === null) return { kind: "none" };
|
|
911
|
-
try {
|
|
912
|
-
return { kind: "ok", client: getStripeClient(), mode: source.mode };
|
|
913
|
-
} catch {
|
|
914
|
-
return { kind: "unusable" };
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* A client built from the Organization's own key, in its own mode — what
|
|
920
|
-
* #8054's catalog source and #8861's checkout call. A live row on a
|
|
921
|
-
* non-production build is `unusable` before any decrypt (D3); so is a key
|
|
922
|
-
* that does not decrypt and one the factory refuses for the row's mode.
|
|
923
|
-
* Nothing is cached beyond the call (C8). With no stored row, an
|
|
924
|
-
* Organization that owns the deployment's environment key (#9285 AC-8) is sold
|
|
925
|
-
* through it, by the same chokepoint the env checkout path uses; no other
|
|
926
|
-
* Organization ever falls back to it.
|
|
927
|
-
*/
|
|
928
|
-
export async function stripeClientForOrganization(
|
|
929
|
-
organizationId: string,
|
|
930
|
-
): Promise<StripeConnectionClient> {
|
|
931
|
-
const row = await store(() =>
|
|
932
|
-
prisma.stripeConnection.findUnique({
|
|
933
|
-
where: { organizationId },
|
|
934
|
-
select: { credentialCiphertext: true, mode: true, liveAt: true },
|
|
935
|
-
}),
|
|
936
|
-
);
|
|
937
|
-
if (row === null) return environmentClientFor(organizationId);
|
|
938
|
-
if (!isMode(row.mode)) {
|
|
939
|
-
logUnreadableRow(organizationId);
|
|
940
|
-
return { kind: "unusable" };
|
|
941
|
-
}
|
|
942
|
-
if (row.mode === "live" && !isProductionBuild()) return { kind: "unusable" };
|
|
943
|
-
const key = decryptStripeConnectionValue(row.credentialCiphertext, organizationId, "credential");
|
|
944
|
-
if (key === null) {
|
|
945
|
-
logUnreadableValue(organizationId);
|
|
946
|
-
return { kind: "unusable" };
|
|
947
|
-
}
|
|
948
|
-
const client = stripeClientForConnection({ organizationId, key, mode: row.mode, liveAt: row.liveAt });
|
|
949
|
-
if (client === null) return { kind: "unusable" };
|
|
950
|
-
return { kind: "ok", client, mode: row.mode };
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// ─── The rekey walk (D13, C10) ──────────────────────────────────────────────
|
|
954
|
-
|
|
955
|
-
export type StripeConnectionsRekeyResult =
|
|
956
|
-
| {
|
|
957
|
-
ok: true;
|
|
958
|
-
scanned: number;
|
|
959
|
-
current: number;
|
|
960
|
-
rekeyed: number;
|
|
961
|
-
changed: number;
|
|
962
|
-
unreadable: number;
|
|
963
|
-
remaining: number;
|
|
964
|
-
}
|
|
965
|
-
| { ok: false; reason: "key_not_configured" | "not_production" };
|
|
966
|
-
|
|
967
|
-
type FieldState = { ok: "current" } | { ok: "previous"; plaintext: string } | { ok: false };
|
|
968
|
-
|
|
969
|
-
function fieldState(stored: string, organizationId: string, field: StripeConnectionField): FieldState {
|
|
970
|
-
if (decryptUnder(stored, organizationId, field, config.STRIPE_CONNECTION_KEY) !== null) {
|
|
971
|
-
return { ok: "current" };
|
|
972
|
-
}
|
|
973
|
-
const plaintext = decryptUnder(stored, organizationId, field, config.STRIPE_CONNECTION_KEY_PREVIOUS);
|
|
974
|
-
return plaintext === null ? { ok: false } : { ok: "previous", plaintext };
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
/**
|
|
978
|
-
* Re-encrypt every row still under the previous key with the current one.
|
|
979
|
-
* A row whose every value is current is untouched; a rekeyed row keeps the
|
|
980
|
-
* `updatedAt` it had, so an owner's "updated" date never moves for a
|
|
981
|
-
* rotation; a row an owner wrote in between is left alone (`changed`); a
|
|
982
|
-
* value under neither key is `unreadable` and untouched. Idempotent: a second
|
|
983
|
-
* run rekeys nothing. `remaining` counts only what another run could rekey.
|
|
984
|
-
*
|
|
985
|
-
* It runs on a production build only (`not_production` otherwise, nothing
|
|
986
|
-
* read): a walk from a preview or a development machine pointed at
|
|
987
|
-
* production's database with another key would re-encrypt every connection
|
|
988
|
-
* under a key production lacks (D3), and one that skipped live rows would
|
|
989
|
-
* report `remaining=0` while they were still under the previous key.
|
|
990
|
-
*/
|
|
991
|
-
export async function rekeyStripeConnections(
|
|
992
|
-
{ pageSize = 100 }: { pageSize?: number } = {},
|
|
993
|
-
): Promise<StripeConnectionsRekeyResult> {
|
|
994
|
-
const take = Math.max(1, Math.floor(pageSize));
|
|
995
|
-
const cipherKey = config.STRIPE_CONNECTION_KEY;
|
|
996
|
-
if (!cipherKey) return { ok: false, reason: "key_not_configured" };
|
|
997
|
-
if (!isProductionBuild()) return { ok: false, reason: "not_production" };
|
|
998
|
-
const counts = { scanned: 0, current: 0, rekeyed: 0, changed: 0, unreadable: 0 };
|
|
999
|
-
|
|
1000
|
-
let lastId: string | undefined;
|
|
1001
|
-
for (;;) {
|
|
1002
|
-
const after = lastId;
|
|
1003
|
-
const page = await store(() =>
|
|
1004
|
-
prisma.stripeConnection.findMany({
|
|
1005
|
-
where: after === undefined ? {} : { id: { gt: after } },
|
|
1006
|
-
orderBy: { id: "asc" },
|
|
1007
|
-
take,
|
|
1008
|
-
select: {
|
|
1009
|
-
id: true,
|
|
1010
|
-
organizationId: true,
|
|
1011
|
-
mode: true,
|
|
1012
|
-
credentialCiphertext: true,
|
|
1013
|
-
webhookSecretCiphertext: true,
|
|
1014
|
-
updatedAt: true,
|
|
1015
|
-
},
|
|
1016
|
-
}),
|
|
1017
|
-
);
|
|
1018
|
-
for (const row of page) {
|
|
1019
|
-
counts.scanned += 1;
|
|
1020
|
-
const outcome = await rekeyRow(row, cipherKey);
|
|
1021
|
-
counts[outcome] += 1;
|
|
1022
|
-
}
|
|
1023
|
-
if (page.length < take) break;
|
|
1024
|
-
lastId = page[page.length - 1]!.id;
|
|
1025
|
-
}
|
|
1026
|
-
return { ok: true, ...counts, remaining: counts.changed };
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
async function rekeyRow(
|
|
1030
|
-
row: {
|
|
1031
|
-
organizationId: string;
|
|
1032
|
-
mode: string;
|
|
1033
|
-
credentialCiphertext: string;
|
|
1034
|
-
webhookSecretCiphertext: string | null;
|
|
1035
|
-
updatedAt: Date;
|
|
1036
|
-
},
|
|
1037
|
-
cipherKey: Buffer,
|
|
1038
|
-
): Promise<"current" | "rekeyed" | "changed" | "unreadable"> {
|
|
1039
|
-
const { organizationId } = row;
|
|
1040
|
-
if (!isMode(row.mode)) {
|
|
1041
|
-
logUnreadableRow(organizationId);
|
|
1042
|
-
return "unreadable";
|
|
1043
|
-
}
|
|
1044
|
-
const secret = secretField(row.mode);
|
|
1045
|
-
const credential = fieldState(row.credentialCiphertext, organizationId, "credential");
|
|
1046
|
-
const webhook =
|
|
1047
|
-
row.webhookSecretCiphertext === null
|
|
1048
|
-
? ({ ok: "current" } as const)
|
|
1049
|
-
: fieldState(row.webhookSecretCiphertext, organizationId, secret);
|
|
1050
|
-
if (credential.ok === false || webhook.ok === false) {
|
|
1051
|
-
logUnreadableValue(organizationId);
|
|
1052
|
-
return "unreadable";
|
|
1053
|
-
}
|
|
1054
|
-
if (credential.ok === "current" && webhook.ok === "current") return "current";
|
|
1055
|
-
|
|
1056
|
-
const written = await writeIfUnchanged(organizationId, row.updatedAt, {
|
|
1057
|
-
...(credential.ok === "previous" && {
|
|
1058
|
-
credentialCiphertext: encryptStripeConnectionValue(
|
|
1059
|
-
credential.plaintext,
|
|
1060
|
-
organizationId,
|
|
1061
|
-
"credential",
|
|
1062
|
-
cipherKey,
|
|
1063
|
-
),
|
|
1064
|
-
}),
|
|
1065
|
-
...(webhook.ok === "previous" && {
|
|
1066
|
-
webhookSecretCiphertext: encryptStripeConnectionValue(
|
|
1067
|
-
webhook.plaintext,
|
|
1068
|
-
organizationId,
|
|
1069
|
-
secret,
|
|
1070
|
-
cipherKey,
|
|
1071
|
-
),
|
|
1072
|
-
}),
|
|
1073
|
-
updatedAt: row.updatedAt,
|
|
1074
|
-
});
|
|
1075
|
-
return written ? "rekeyed" : "changed";
|
|
1076
|
-
}
|
|
1077
|
-
|
|
1078
|
-
/** The bin's one output line: counts only, never a key or a row id. */
|
|
1079
|
-
export function formatRekeyReport(result: StripeConnectionsRekeyResult): string {
|
|
1080
|
-
if (!result.ok) return `[stripe-connections-rekey] ${result.reason}`;
|
|
1081
|
-
const { scanned, current, rekeyed, changed, unreadable, remaining } = result;
|
|
1082
|
-
return `[stripe-connections-rekey] scanned=${scanned} current=${current} rekeyed=${rekeyed} changed=${changed} unreadable=${unreadable} remaining=${remaining}`;
|
|
1083
|
-
}
|