create-ailk 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +11 -9
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +9 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +98 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +184 -11
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +77 -16
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +10 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +17 -14
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
- package/templates/apps/api/src/server.ts +140 -51
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +124 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +315 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +31 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +31 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +30 -2
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -11,18 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import cors from "@fastify/cors";
|
|
13
13
|
import rateLimit from "@fastify/rate-limit";
|
|
14
|
-
import { idRegex } from "@working-theory/ids";
|
|
15
14
|
import { logger } from "@working-theory/observability";
|
|
16
15
|
import Fastify from "fastify";
|
|
17
16
|
import type {
|
|
18
17
|
FastifyPluginAsync,
|
|
19
18
|
FastifyPluginCallback,
|
|
19
|
+
FastifyReply,
|
|
20
20
|
FastifyRequest,
|
|
21
21
|
} from "fastify";
|
|
22
22
|
|
|
23
23
|
import { config } from "./config/index.js";
|
|
24
24
|
import { isModuleEnabled, servedModules } from "./config/modules.js";
|
|
25
|
-
import { registerErrorSerializer } from "./lib/error-serializer.js";
|
|
25
|
+
import { redactLogPath, registerErrorSerializer } from "./lib/error-serializer.js";
|
|
26
26
|
import { validateLeadCaptureDir } from "./lib/lead-capture-store.js";
|
|
27
27
|
import { registerLicenseKeyStore } from "./lib/license-key-store.js";
|
|
28
28
|
import { requireCommerceClaim } from "./lib/require-commerce-claim.js";
|
|
@@ -35,7 +35,10 @@ import {
|
|
|
35
35
|
buildTrustedKeyDigests,
|
|
36
36
|
DEFAULT_SCHEDULE_PER_KEY_PER_MINUTE,
|
|
37
37
|
DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
38
|
+
DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
|
|
38
39
|
resolveRateLimitIdentity,
|
|
40
|
+
SITE_KEY_IDENTITY_PREFIX,
|
|
41
|
+
siteKeyReader,
|
|
39
42
|
} from "./middleware/rate-limit.js";
|
|
40
43
|
import { openapiRoutes } from "./openapi/index.js";
|
|
41
44
|
import {
|
|
@@ -65,12 +68,18 @@ import {
|
|
|
65
68
|
import { getPageRoute } from "./routes/content/get-page.js";
|
|
66
69
|
import { listPagesRoute } from "./routes/content/list-pages.js";
|
|
67
70
|
import { listTypesRoute } from "./routes/content/list-types.js";
|
|
71
|
+
import { offeringsRoute } from "./routes/content/offerings.js";
|
|
72
|
+
import { pricingRoute } from "./routes/content/pricing.js";
|
|
68
73
|
import {
|
|
69
74
|
CONTENT_RATE_LIMIT_AUTH,
|
|
70
75
|
CONTENT_RATE_LIMIT_UNAUTH,
|
|
71
76
|
CONTENT_RATE_LIMIT_WINDOW,
|
|
72
77
|
} from "./routes/content/rate-limit.js";
|
|
73
78
|
import { searchContentRoute } from "./routes/content/search.js";
|
|
79
|
+
import {
|
|
80
|
+
shownOfferingsRoute,
|
|
81
|
+
type ShownOfferingsRouteOptions,
|
|
82
|
+
} from "./routes/content/shown-offerings.js";
|
|
74
83
|
import {
|
|
75
84
|
leadCallbackRoute,
|
|
76
85
|
leadCaptureStatusRoute,
|
|
@@ -84,6 +93,8 @@ import {
|
|
|
84
93
|
shouldMountTestAuth,
|
|
85
94
|
} from "./routes/test/auth/session.js";
|
|
86
95
|
import { consentLinkSecretConfigured } from "./services/consent.js";
|
|
96
|
+
import { stripeCatalogSourceFor } from "./services/offerings/source.js";
|
|
97
|
+
import { resolveCallerSite } from "./services/public-site-resolver.js";
|
|
87
98
|
|
|
88
99
|
/**
|
|
89
100
|
* What a request looks like in a log line — Fastify's own default shape, minus
|
|
@@ -99,56 +110,43 @@ import { consentLinkSecretConfigured } from "./services/consent.js";
|
|
|
99
110
|
* Nothing that identifies a request is lost: a route parameter such as `:id`
|
|
100
111
|
* lives in the PATH and survives. Only the secret after `?` is dropped.
|
|
101
112
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* #6216 D11/§ 9 A-3 — the SAME rule now carries a second arm for
|
|
112
|
-
* `lead_…`-shaped segments. Fastify's automatic "incoming request" line is
|
|
113
|
-
* emitted by its internal hook BEFORE any route hook can intervene, so
|
|
114
|
-
* every `GET`/`PATCH .../workspaces/:workspaceId/messages/:leadId` would
|
|
115
|
-
* otherwise write the lead id into the log beside `remoteAddress` —
|
|
116
|
-
* completing exactly the `leadId` ↔ `siteId`/`ip`/`user-agent` correlation
|
|
117
|
-
* `routes/leads/index.ts` and this file's own PII posture refuse. No
|
|
118
|
-
* route-level discipline can fix it; this is the only place that can.
|
|
113
|
+
* Path segments that are themselves credentials or identifiers — a `cs_…`
|
|
114
|
+
* Checkout Session id (#6084 D14), a `lead_…` id (#6216 D11), a `person_…` id
|
|
115
|
+
* (#8438 SEC-8438-10) and a webhook connection token (#8932 D10) — are
|
|
116
|
+
* redacted by `redactLogPath` (`lib/error-serializer.ts`), the ONE path
|
|
117
|
+
* redactor this serializer and the global error handler share, so a request
|
|
118
|
+
* line and an error line can never disagree about what a path may carry.
|
|
119
|
+
* Fastify's automatic "incoming request" line is emitted by its internal hook
|
|
120
|
+
* BEFORE any route hook can intervene, so no route-level discipline can fix
|
|
121
|
+
* it; this is the only place that can.
|
|
119
122
|
*/
|
|
120
|
-
const SESSION_ID_LOG_SEGMENT = /^cs_[A-Za-z0-9_]+$/;
|
|
121
|
-
const LEAD_ID_LOG_SEGMENT = idRegex("lead");
|
|
122
|
-
// #8438 SEC-8438-10 — a third arm for `person_…` segments. The consents route
|
|
123
|
-
// (and every other `…/people/:personId…` route) would otherwise write a full
|
|
124
|
-
// Person id into the request log line, beside `remoteAddress`.
|
|
125
|
-
const PERSON_ID_LOG_SEGMENT = idRegex("person");
|
|
126
|
-
|
|
127
|
-
function redactSensitiveIdSegments(url: string): string {
|
|
128
|
-
return url
|
|
129
|
-
.split("/")
|
|
130
|
-
.map((segment) =>
|
|
131
|
-
SESSION_ID_LOG_SEGMENT.test(segment) ||
|
|
132
|
-
LEAD_ID_LOG_SEGMENT.test(segment) ||
|
|
133
|
-
PERSON_ID_LOG_SEGMENT.test(segment)
|
|
134
|
-
? `${segment.slice(0, 8)}…`
|
|
135
|
-
: segment,
|
|
136
|
-
)
|
|
137
|
-
.join("/");
|
|
138
|
-
}
|
|
139
|
-
|
|
140
123
|
export function requestLogSerializer(request: FastifyRequest) {
|
|
141
|
-
const raw = request.url;
|
|
142
|
-
const queryAt = raw.indexOf("?");
|
|
143
|
-
const path = queryAt === -1 ? raw : raw.slice(0, queryAt);
|
|
144
124
|
return {
|
|
145
125
|
method: request.method,
|
|
146
|
-
url:
|
|
126
|
+
url: redactLogPath(request.url),
|
|
147
127
|
host: request.hostname,
|
|
148
128
|
remoteAddress: request.ip,
|
|
149
129
|
};
|
|
150
130
|
}
|
|
151
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Fastify's default 404, with the logged path redacted (#8932 SEC-8932-10).
|
|
134
|
+
* The default handler logs `Route METHOD:URL not found` with the raw URL in
|
|
135
|
+
* the message, where `requestLogSerializer` never reaches, so a mis-pasted
|
|
136
|
+
* webhook URL — a trailing slash, a double slash, an extra segment — would
|
|
137
|
+
* log its connection token on every Stripe retry. The answer is the default
|
|
138
|
+
* one, byte for byte; only the log line changes.
|
|
139
|
+
*/
|
|
140
|
+
export function notFoundHandler(request: FastifyRequest, reply: FastifyReply) {
|
|
141
|
+
const { method, url = "" } = request.raw;
|
|
142
|
+
request.log.info(`Route ${method}:${redactLogPath(url)} not found`);
|
|
143
|
+
return reply.code(404).send({
|
|
144
|
+
message: `Route ${method}:${url} not found`,
|
|
145
|
+
error: "Not Found",
|
|
146
|
+
statusCode: 404,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
152
150
|
/**
|
|
153
151
|
* Paid-module SLICE entry points, keyed by the export each branch needs
|
|
154
152
|
* (#5182).
|
|
@@ -234,6 +232,8 @@ const WORKSPACES_MESSAGES = "./routes/workspaces/messages.js";
|
|
|
234
232
|
// #7535 — the Inbox submissions and subscribers reads, siblings of
|
|
235
233
|
// WORKSPACES_MESSAGES in the same workspace/multi-tenant scope.
|
|
236
234
|
const WORKSPACES_SUBMISSIONS = "./routes/workspaces/submissions.js";
|
|
235
|
+
// #9170 — the Inbox orders read, the SAME scope.
|
|
236
|
+
const WORKSPACES_ORDERS = "./routes/workspaces/orders.js";
|
|
237
237
|
const WORKSPACES_SUBSCRIBERS = "./routes/workspaces/subscribers.js";
|
|
238
238
|
// #852 D8 — the People reads (the workspace's clients), the SAME scope.
|
|
239
239
|
const WORKSPACES_PEOPLE = "./routes/workspaces/people.js";
|
|
@@ -289,6 +289,7 @@ const ASSETS_UPLOAD = "./routes/assets/upload.js";
|
|
|
289
289
|
const ASSETS_READ = "./routes/assets/read.js";
|
|
290
290
|
const ASSETS_MANAGE = "./routes/assets/manage.js";
|
|
291
291
|
const BILLING_INDEX = "./routes/billing/index.js";
|
|
292
|
+
const WORKSPACES_OFFERINGS = "./routes/workspaces/offerings.js";
|
|
292
293
|
const WAITLIST_EXPERIMENTS_INDEX = "./routes/waitlist-experiments/index.js";
|
|
293
294
|
const SCHEDULE_INDEX = "./routes/schedule/index.js";
|
|
294
295
|
const FLOWS_INDEX = "./routes/flows/index.js";
|
|
@@ -346,6 +347,11 @@ const SLICE_PLUGINS = {
|
|
|
346
347
|
specifier: WORKSPACES_SUBMISSIONS,
|
|
347
348
|
load: () => import(WORKSPACES_SUBMISSIONS),
|
|
348
349
|
},
|
|
350
|
+
// #9170 — the Inbox orders read, the SAME scope.
|
|
351
|
+
workspaceOrderRoutes: {
|
|
352
|
+
specifier: WORKSPACES_ORDERS,
|
|
353
|
+
load: () => import(WORKSPACES_ORDERS),
|
|
354
|
+
},
|
|
349
355
|
workspaceSubscriberRoutes: {
|
|
350
356
|
specifier: WORKSPACES_SUBSCRIBERS,
|
|
351
357
|
load: () => import(WORKSPACES_SUBSCRIBERS),
|
|
@@ -481,6 +487,11 @@ const SLICE_PLUGINS = {
|
|
|
481
487
|
specifier: BILLING_INDEX,
|
|
482
488
|
load: () => import(BILLING_INDEX),
|
|
483
489
|
},
|
|
490
|
+
// #8926 D1 — the workspace Offerings routes, multi-tenant only.
|
|
491
|
+
workspaceOfferingsRoutes: {
|
|
492
|
+
specifier: WORKSPACES_OFFERINGS,
|
|
493
|
+
load: () => import(WORKSPACES_OFFERINGS),
|
|
494
|
+
},
|
|
484
495
|
waitlistExperimentRoutes: {
|
|
485
496
|
specifier: WAITLIST_EXPERIMENTS_INDEX,
|
|
486
497
|
load: () => import(WAITLIST_EXPERIMENTS_INDEX),
|
|
@@ -504,6 +515,11 @@ const SLICE_PLUGINS = {
|
|
|
504
515
|
specifier: SCHEDULE_INDEX,
|
|
505
516
|
load: () => import(SCHEDULE_INDEX),
|
|
506
517
|
},
|
|
518
|
+
// #8843 D1 — the meetings read; same specifier as its schedule siblings.
|
|
519
|
+
listMeetingsRoute: {
|
|
520
|
+
specifier: SCHEDULE_INDEX,
|
|
521
|
+
load: () => import(SCHEDULE_INDEX),
|
|
522
|
+
},
|
|
507
523
|
// #6085 — the tenant-guarded appointments read; same specifier as the
|
|
508
524
|
// three anonymous schedule routes above (§ D6, no new SLICE_PLUGINS
|
|
509
525
|
// specifier constant needed).
|
|
@@ -539,6 +555,11 @@ const SLICE_PLUGINS = {
|
|
|
539
555
|
specifier: DELIVERABLES_INDEX,
|
|
540
556
|
load: () => import(DELIVERABLES_INDEX),
|
|
541
557
|
},
|
|
558
|
+
// #8848 D5 — an ungated file's bytes, in its own `deliverableopen` scope.
|
|
559
|
+
deliverableOpenRoute: {
|
|
560
|
+
specifier: DELIVERABLES_INDEX,
|
|
561
|
+
load: () => import(DELIVERABLES_INDEX),
|
|
562
|
+
},
|
|
542
563
|
projectListingStartRoute: {
|
|
543
564
|
specifier: PROJECT_LISTINGS_INDEX,
|
|
544
565
|
load: () => import(PROJECT_LISTINGS_INDEX),
|
|
@@ -840,6 +861,17 @@ export async function build() {
|
|
|
840
861
|
(msg) => server.log.warn(msg),
|
|
841
862
|
);
|
|
842
863
|
|
|
864
|
+
// #8805 — the sign-up evidence read's site key, verified once per request
|
|
865
|
+
// and shared by the global ceiling and the `signupevidence` scope below. A
|
|
866
|
+
// registered, unrevoked spoke key (`resolveCallerSite`, the registry lookup
|
|
867
|
+
// every spoke-aware route uses) is counted per key; a malformed, unknown or
|
|
868
|
+
// revoked one, or none, keeps `req.ip`. It decides the bucket only — the
|
|
869
|
+
// route still serves `resolveSiteWorkspaceId()`.
|
|
870
|
+
const signUpEvidenceSiteKey = siteKeyReader(async (req) => {
|
|
871
|
+
const caller = await resolveCallerSite(req);
|
|
872
|
+
return caller.kind === "spoke" ? caller.spokeId : null;
|
|
873
|
+
});
|
|
874
|
+
|
|
843
875
|
// Collect the route table this server registers (#5314).
|
|
844
876
|
//
|
|
845
877
|
// Installed here — after `Fastify()` and before the first `register()` — so
|
|
@@ -870,6 +902,7 @@ export async function build() {
|
|
|
870
902
|
// so connection strings, hostnames, stack traces, and SDK error details
|
|
871
903
|
// never reach the client. Full detail still goes to pino at error level.
|
|
872
904
|
registerErrorSerializer(server);
|
|
905
|
+
server.setNotFoundHandler(notFoundHandler);
|
|
873
906
|
|
|
874
907
|
// Register CORS — public read API + the trusted browser origins (#7395).
|
|
875
908
|
// `origin` is ALWAYS a copy of `config.TRUSTED_ORIGINS`, the frozen list
|
|
@@ -918,11 +951,23 @@ export async function build() {
|
|
|
918
951
|
// visitor's own ceiling here too, or a spoke whose visitors share one
|
|
919
952
|
// egress IP would still be capped at this limit in total. For every other
|
|
920
953
|
// request it returns `req.ip`, the plugin's own default key.
|
|
954
|
+
//
|
|
955
|
+
// #8805: a sign-up evidence read with a verified site key is keyed
|
|
956
|
+
// `site,<spokeId>` and let through here — its scope's per-key ceiling is
|
|
957
|
+
// its one limit, so an anonymous caller spoofing the web's egress address
|
|
958
|
+
// cannot spend it, and many web instances behind that address share no
|
|
959
|
+
// IP bucket.
|
|
921
960
|
await server.register(rateLimit, {
|
|
922
961
|
max: CONTENT_RATE_LIMIT_UNAUTH,
|
|
923
962
|
timeWindow: CONTENT_RATE_LIMIT_WINDOW,
|
|
924
|
-
keyGenerator: (req) =>
|
|
925
|
-
|
|
963
|
+
keyGenerator: async (req) => {
|
|
964
|
+
const siteId = await signUpEvidenceSiteKey(req);
|
|
965
|
+
return siteId === null
|
|
966
|
+
? resolveRateLimitIdentity(req, visitorForwardKeyDigests).identity
|
|
967
|
+
: `${SITE_KEY_IDENTITY_PREFIX}${siteId}`;
|
|
968
|
+
},
|
|
969
|
+
allowList: (_req, key) =>
|
|
970
|
+
typeof key === "string" && key.startsWith(SITE_KEY_IDENTITY_PREFIX),
|
|
926
971
|
});
|
|
927
972
|
|
|
928
973
|
// Health check
|
|
@@ -1122,6 +1167,10 @@ export async function build() {
|
|
|
1122
1167
|
await tenantScope.register(await sliceRoutePlugin("sessionRoutes"));
|
|
1123
1168
|
// Org-billing routes (#3701) — org-scoped read + Stripe customer-portal.
|
|
1124
1169
|
await tenantScope.register(await sliceRoutePlugin("billingRoutes"));
|
|
1170
|
+
// The workspace Offerings routes (#8926 D1) — the Library → Offerings
|
|
1171
|
+
// tab's read and its selection write, beside the billing routes that
|
|
1172
|
+
// write the Stripe connection they need.
|
|
1173
|
+
await tenantScope.register(await sliceRoutePlugin("workspaceOfferingsRoutes"));
|
|
1125
1174
|
});
|
|
1126
1175
|
}
|
|
1127
1176
|
|
|
@@ -1268,6 +1317,11 @@ export async function build() {
|
|
|
1268
1317
|
await tenantScope.register(
|
|
1269
1318
|
await sliceRoutePlugin("workspaceSubscriberRoutes"),
|
|
1270
1319
|
);
|
|
1320
|
+
// The Inbox orders read (#9170) — the SAME scope and guard: the
|
|
1321
|
+
// workspace's purchases (list, item read), behind `inbox-read`.
|
|
1322
|
+
await tenantScope.register(
|
|
1323
|
+
await sliceRoutePlugin("workspaceOrderRoutes"),
|
|
1324
|
+
);
|
|
1271
1325
|
// The People reads (#852 D8) — the SAME scope and guard: the
|
|
1272
1326
|
// workspace's clients and one Person with its derived timeline,
|
|
1273
1327
|
// behind `people-read` (owner, admin, a granted agent).
|
|
@@ -1385,8 +1439,20 @@ export async function build() {
|
|
|
1385
1439
|
await server.register(await sliceRoutePlugin("publicAssetReadRoute"));
|
|
1386
1440
|
}
|
|
1387
1441
|
await server.register(listTypesRoute);
|
|
1388
|
-
// Before `listPagesRoute`: `search`
|
|
1442
|
+
// Before `listPagesRoute`: `search`, `offerings`, `pricing` and
|
|
1443
|
+
// `shown-offerings` must never be read as a `:type`.
|
|
1389
1444
|
await server.register(searchContentRoute);
|
|
1445
|
+
// The Organization's Stripe catalog source (#8922), so a stale snapshot
|
|
1446
|
+
// refreshes on the service's bounded, never-forced path (#8924 D7, #9124;
|
|
1447
|
+
// the bounds are `refreshWorkspaceSnapshot`'s). The two agent twins read
|
|
1448
|
+
// the shown set through the same read with these same options, so all
|
|
1449
|
+
// three refresh together (#8933 D5).
|
|
1450
|
+
const shownSetRouteOptions: ShownOfferingsRouteOptions = {
|
|
1451
|
+
sourceFor: stripeCatalogSourceFor,
|
|
1452
|
+
};
|
|
1453
|
+
await server.register(offeringsRoute, shownSetRouteOptions);
|
|
1454
|
+
await server.register(pricingRoute, shownSetRouteOptions);
|
|
1455
|
+
await server.register(shownOfferingsRoute, shownSetRouteOptions);
|
|
1390
1456
|
await server.register(listPagesRoute);
|
|
1391
1457
|
await server.register(getPageRoute);
|
|
1392
1458
|
// The hub's sign-in page names the spoke that sent a visitor (#8790 D2):
|
|
@@ -1471,6 +1537,20 @@ export async function build() {
|
|
|
1471
1537
|
// (signal-collectors, D-48): gated PDFs and reports.
|
|
1472
1538
|
if (isModuleEnabled("signal-collectors")) {
|
|
1473
1539
|
await server.register(await sliceRoutePlugin("deliverableDownloadRoute"));
|
|
1540
|
+
// #8848 D5 — the ungated file read, anonymous and keyed by slug, so it
|
|
1541
|
+
// gets its OWN encapsulated scope: `rateLimitMiddleware` breaks
|
|
1542
|
+
// encapsulation, so it is never installed on the grant route's
|
|
1543
|
+
// registration above, whose budget stays as it is. With the hub's
|
|
1544
|
+
// forward-key digests (the route is on FORWARDED_VISITOR_ROUTES), a
|
|
1545
|
+
// site's proxied reads are counted per visitor (SEC-8848-C).
|
|
1546
|
+
await server.register(async (deliverableOpenScope) => {
|
|
1547
|
+
deliverableOpenScope.register(rateLimitMiddleware, {
|
|
1548
|
+
perIpPerMinute: 60,
|
|
1549
|
+
scope: "deliverableopen",
|
|
1550
|
+
trustedKeyDigests: visitorForwardKeyDigests,
|
|
1551
|
+
});
|
|
1552
|
+
await deliverableOpenScope.register(await sliceRoutePlugin("deliverableOpenRoute"));
|
|
1553
|
+
});
|
|
1474
1554
|
}
|
|
1475
1555
|
|
|
1476
1556
|
// Webhooks routes — raw-body parser scoped to this sub-router only so the
|
|
@@ -1586,15 +1666,20 @@ export async function build() {
|
|
|
1586
1666
|
await unsubscribeScope.register(consentUnsubscribeRoute);
|
|
1587
1667
|
});
|
|
1588
1668
|
// #8765 D2 — the sign-up agreements' evidence, beside the consent floor:
|
|
1589
|
-
// anonymous,
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
//
|
|
1669
|
+
// anonymous, outside every module gate (sign-up and consent are never-gate
|
|
1670
|
+
// surfaces). Its own scope, `signupevidence`, so an anonymous burst never
|
|
1671
|
+
// shares a bucket with the cookie banner's log (SEC-8765-D). Its main
|
|
1672
|
+
// caller is the web's own server, whose cache keeps it to one call a minute
|
|
1673
|
+
// per enabled locale per instance. #8805: a call with a verified site key
|
|
1674
|
+
// is counted per key (DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
|
|
1675
|
+
// AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE), never per IP; any
|
|
1676
|
+
// other call keeps 10 a minute per IP.
|
|
1594
1677
|
await server.register(async (signUpEvidenceScope) => {
|
|
1595
1678
|
signUpEvidenceScope.register(rateLimitMiddleware, {
|
|
1596
1679
|
perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
1680
|
+
perKeyPerMinute: DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
|
|
1597
1681
|
scope: "signupevidence",
|
|
1682
|
+
siteKeyOf: signUpEvidenceSiteKey,
|
|
1598
1683
|
});
|
|
1599
1684
|
await signUpEvidenceScope.register(consentSignUpRoute);
|
|
1600
1685
|
});
|
|
@@ -1772,6 +1857,10 @@ export async function build() {
|
|
|
1772
1857
|
await scheduleScope.register(
|
|
1773
1858
|
await sliceRoutePlugin("meetingTypesRoute"),
|
|
1774
1859
|
);
|
|
1860
|
+
// #8843 D1 — the meetings read: the same gate and per-IP limit.
|
|
1861
|
+
await scheduleScope.register(
|
|
1862
|
+
await sliceRoutePlugin("listMeetingsRoute"),
|
|
1863
|
+
);
|
|
1775
1864
|
});
|
|
1776
1865
|
}
|
|
1777
1866
|
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file checkout-plan.test.ts
|
|
3
|
+
* @description The hub's checkout plan rule end to end (#8793, spec
|
|
4
|
+
* docs/specs/8793-the-hub-s-checkout-plan-spec.md D1–D3; AC-3, AC-4, AC-5):
|
|
5
|
+
* a spoke's marked checkout lead never writes a Person's plan — it holds it —
|
|
6
|
+
* and the held plan is released only when `resolveMemberPersonId` first links
|
|
7
|
+
* the Person to the signed-in user whose VERIFIED email is the Person's own.
|
|
8
|
+
* A checkout that types another Person's email cannot change that Person's
|
|
9
|
+
* plan, and the lead, the purchase message, the purchase email and the
|
|
10
|
+
* consent grant are written exactly as before.
|
|
11
|
+
*
|
|
12
|
+
* Runs `routeLead` and `resolveMemberPersonId` with the real `person.ts`
|
|
13
|
+
* over `createInMemoryPrisma()`, which enforces the Person's
|
|
14
|
+
* `(workspaceId, email)` and `(workspaceId, userId)` keys.
|
|
15
|
+
*
|
|
16
|
+
* External dependency boundaries mocked:
|
|
17
|
+
* - apps/api/src/lib/prisma — the in-memory, where-clause-honouring double.
|
|
18
|
+
* Its `lead.count` is arranged per test: the purchase email's cap reads
|
|
19
|
+
* with `mode: "insensitive"`, a filter the double does not model.
|
|
20
|
+
* - @working-theory/email — the operator notification and purchase email.
|
|
21
|
+
* - ../plan-offerings.js — the workspace's content-adapter read.
|
|
22
|
+
* - consent.recordCheckoutConsent — spied; the ledger's own transaction is
|
|
23
|
+
* `consent.test.ts`'s, and here only its call is the claim.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); the in-memory double is the repo-standard where-honouring test double for it.
|
|
27
|
+
jest.mock("../../lib/prisma.js", () =>
|
|
28
|
+
jest
|
|
29
|
+
.requireActual<typeof import("../../__tests__/factories/in-memory-prisma")>(
|
|
30
|
+
"../../__tests__/factories/in-memory-prisma",
|
|
31
|
+
)
|
|
32
|
+
.createInMemoryPrisma(),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const mockSendLeadNotification = jest.fn();
|
|
36
|
+
const mockSendPurchaseConfirmationEmail = jest.fn();
|
|
37
|
+
jest.mock("@working-theory/email", () => ({
|
|
38
|
+
sendLeadNotification: (...args: unknown[]) =>
|
|
39
|
+
mockSendLeadNotification(...args),
|
|
40
|
+
sendPurchaseConfirmationEmail: (...args: unknown[]) =>
|
|
41
|
+
mockSendPurchaseConfirmationEmail(...args),
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const mockListPlanOfferings = jest.fn();
|
|
45
|
+
// eslint-disable-next-line no-restricted-syntax -- plan-offerings.ts is the workspace's content-adapter read (the database adapter, an external boundary), the same seam lead-routing.test.ts mocks.
|
|
46
|
+
jest.mock("../plan-offerings.js", () => ({
|
|
47
|
+
listPlanOfferings: (...args: unknown[]) => mockListPlanOfferings(...args),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
import { logger } from "@working-theory/observability";
|
|
51
|
+
|
|
52
|
+
import { prisma } from "../../lib/prisma.js";
|
|
53
|
+
import * as consent from "../consent";
|
|
54
|
+
import { routeLead } from "../lead-routing";
|
|
55
|
+
import { resolveMemberPersonId } from "../person";
|
|
56
|
+
|
|
57
|
+
type Row = Record<string, unknown>;
|
|
58
|
+
// The in-memory double's own surface (`__db`, `__calls`) is not on the
|
|
59
|
+
// PrismaClient type the import carries, so it is named here once.
|
|
60
|
+
// eslint-disable-next-line no-restricted-syntax -- justified above
|
|
61
|
+
const store = prisma as unknown as {
|
|
62
|
+
__db: Record<string, Row[]>;
|
|
63
|
+
lead: Record<string, jest.Mock>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const WS = "wksp_00000000-0000-7000-8000-000000008793";
|
|
67
|
+
const KEY = "cs_live_h8793Hold"; // gitleaks:allow -- a synthetic Checkout Session id shape (#8793 AC-3), not a credential
|
|
68
|
+
const KEY_2 = "cs_live_h8793Hold2"; // gitleaks:allow -- a second synthetic Checkout Session id shape (#8793 AC-4), not a credential
|
|
69
|
+
const VICTIM = "victim@x.io";
|
|
70
|
+
const U = "user_00000000-0000-7000-8000-000000008793";
|
|
71
|
+
const ENV_KEYS = [
|
|
72
|
+
"DATABASE_URL",
|
|
73
|
+
"NODE_ENV",
|
|
74
|
+
"AILK_LEAD_NOTIFICATION_TO",
|
|
75
|
+
"AILK_LEAD_NOTIFICATION_FROM",
|
|
76
|
+
"AILK_DELIVERABLE_EMAIL_FROM",
|
|
77
|
+
] as const;
|
|
78
|
+
|
|
79
|
+
const sourceMeta = {
|
|
80
|
+
ip: "127.0.0.1",
|
|
81
|
+
userAgent: "jest",
|
|
82
|
+
receivedAt: new Date().toISOString(),
|
|
83
|
+
viaSpoke: true,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const people = () => (store.__db["person"] ??= []);
|
|
87
|
+
const users = () => (store.__db["user"] ??= []);
|
|
88
|
+
const leads = () => (store.__db["lead"] ??= []);
|
|
89
|
+
const interactions = () => (store.__db["interaction"] ??= []);
|
|
90
|
+
const victim = () => people().find((p) => p["email"] === VICTIM);
|
|
91
|
+
|
|
92
|
+
function checkout(email: string, offering: string, extra: Row = {}) {
|
|
93
|
+
return {
|
|
94
|
+
kind: "submit" as const,
|
|
95
|
+
siteId: WS,
|
|
96
|
+
source: "web",
|
|
97
|
+
email,
|
|
98
|
+
payload: {
|
|
99
|
+
site: "wendi",
|
|
100
|
+
page: "checkout",
|
|
101
|
+
offering,
|
|
102
|
+
amountTotal: 900,
|
|
103
|
+
currency: "usd",
|
|
104
|
+
...extra,
|
|
105
|
+
} as Record<string, unknown>,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A Person of `WS`, optionally linked to a verified user. */
|
|
110
|
+
function seedVictim(row: { userId?: string; planOffering?: string | null }): void {
|
|
111
|
+
people().push({
|
|
112
|
+
id: "person_00000000-0000-7000-8000-000000008793",
|
|
113
|
+
workspaceId: WS,
|
|
114
|
+
email: VICTIM,
|
|
115
|
+
name: null,
|
|
116
|
+
userId: null,
|
|
117
|
+
billingProvider: null,
|
|
118
|
+
billingCustomerId: null,
|
|
119
|
+
planOffering: null,
|
|
120
|
+
contactConfirmedAt: null,
|
|
121
|
+
contactConfirmedBy: null,
|
|
122
|
+
createdAt: new Date(Date.UTC(2026, 0, 1)),
|
|
123
|
+
...row,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const verifiedUser = (id: string, email: string) =>
|
|
128
|
+
users().push({ id, email, emailVerified: true });
|
|
129
|
+
|
|
130
|
+
let saved: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
|
|
131
|
+
let info: jest.SpyInstance;
|
|
132
|
+
let recordGrant: jest.SpyInstance;
|
|
133
|
+
|
|
134
|
+
const planLines = () =>
|
|
135
|
+
info.mock.calls
|
|
136
|
+
.map(([fields]) => fields as Row)
|
|
137
|
+
.filter((fields) => fields?.["event"] === "checkout-plan");
|
|
138
|
+
|
|
139
|
+
/** Let the fired (void) purchase email run to its end. */
|
|
140
|
+
const settle = async () => {
|
|
141
|
+
for (let i = 0; i < 5; i += 1)
|
|
142
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
for (const key of Object.keys(store.__db)) delete store.__db[key];
|
|
147
|
+
saved = {};
|
|
148
|
+
for (const k of ENV_KEYS) saved[k] = process.env[k];
|
|
149
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
150
|
+
delete process.env.NODE_ENV;
|
|
151
|
+
process.env.AILK_LEAD_NOTIFICATION_TO = "owner@example.com";
|
|
152
|
+
process.env.AILK_LEAD_NOTIFICATION_FROM = "noreply@example.com";
|
|
153
|
+
process.env.AILK_DELIVERABLE_EMAIL_FROM = "orders@example.com";
|
|
154
|
+
mockSendLeadNotification.mockReset().mockResolvedValue({ ok: true, messageId: "m" });
|
|
155
|
+
mockSendPurchaseConfirmationEmail
|
|
156
|
+
.mockReset()
|
|
157
|
+
.mockResolvedValue({ ok: true, messageId: "m2" });
|
|
158
|
+
mockListPlanOfferings.mockReset().mockResolvedValue([]);
|
|
159
|
+
store.lead["count"]!.mockReset().mockResolvedValue(1);
|
|
160
|
+
info = jest.spyOn(logger, "info").mockImplementation(() => undefined as never);
|
|
161
|
+
recordGrant = jest
|
|
162
|
+
.spyOn(consent, "recordCheckoutConsent")
|
|
163
|
+
.mockResolvedValue("recorded");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
for (const k of ENV_KEYS) {
|
|
168
|
+
if (saved[k] === undefined) delete process.env[k];
|
|
169
|
+
else process.env[k] = saved[k];
|
|
170
|
+
}
|
|
171
|
+
info.mockRestore();
|
|
172
|
+
recordGrant.mockRestore();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe("a checkout that types another Person's email cannot change that Person's plan (#8793 AC-3)", () => {
|
|
176
|
+
it("a linked victim's set plan stays, and the checkout's line is held", async () => {
|
|
177
|
+
verifiedUser(U, VICTIM);
|
|
178
|
+
seedVictim({ userId: U, planOffering: "premium" });
|
|
179
|
+
await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
|
|
180
|
+
idempotencyKey: KEY,
|
|
181
|
+
});
|
|
182
|
+
expect(victim()!["planOffering"]).toBe("premium");
|
|
183
|
+
expect(planLines()).toEqual([
|
|
184
|
+
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("a linked victim's blank plan stays blank, through the victim's next member read", async () => {
|
|
189
|
+
verifiedUser(U, VICTIM);
|
|
190
|
+
seedVictim({ userId: U });
|
|
191
|
+
await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
|
|
192
|
+
idempotencyKey: KEY,
|
|
193
|
+
});
|
|
194
|
+
expect(victim()!["planOffering"]).toBeNull();
|
|
195
|
+
expect(await resolveMemberPersonId(WS, U, VICTIM)).toBe(victim()!["id"]);
|
|
196
|
+
expect(victim()!["planOffering"]).toBeNull();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("a stranger's later purchase cannot decide an unlinked victim's held plan", async () => {
|
|
200
|
+
seedVictim({});
|
|
201
|
+
leads().push({
|
|
202
|
+
id: "lead_00000000-0000-7000-8000-000000000001",
|
|
203
|
+
workspaceId: WS,
|
|
204
|
+
siteId: WS,
|
|
205
|
+
personId: victim()!["id"],
|
|
206
|
+
email: VICTIM,
|
|
207
|
+
source: "web",
|
|
208
|
+
spokeCheckout: true,
|
|
209
|
+
payload: { site: "wendi", page: "checkout", offering: "wendi_premium" },
|
|
210
|
+
createdAt: new Date(Date.UTC(2026, 0, 2)),
|
|
211
|
+
});
|
|
212
|
+
await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
|
|
213
|
+
idempotencyKey: KEY,
|
|
214
|
+
});
|
|
215
|
+
verifiedUser(U, VICTIM);
|
|
216
|
+
const log = { warn: jest.fn() };
|
|
217
|
+
expect(await resolveMemberPersonId(WS, U, VICTIM, log)).toBe(victim()!["id"]);
|
|
218
|
+
expect(victim()!["planOffering"]).toBeNull();
|
|
219
|
+
// The release ran and refused: the purchases disagree.
|
|
220
|
+
expect(log.warn).toHaveBeenCalledWith(
|
|
221
|
+
{ event: "checkout-plan-release", outcome: "ambiguous" },
|
|
222
|
+
expect.any(String),
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("an unlinked victim's operator-set plan stays through the first link", async () => {
|
|
227
|
+
seedVictim({ planOffering: "premium" });
|
|
228
|
+
await routeLead(checkout("VICTIM@x.io", "wendi_basic"), sourceMeta, {
|
|
229
|
+
idempotencyKey: KEY,
|
|
230
|
+
});
|
|
231
|
+
verifiedUser(U, VICTIM);
|
|
232
|
+
expect(await resolveMemberPersonId(WS, U, VICTIM)).toBe(victim()!["id"]);
|
|
233
|
+
expect(victim()!["planOffering"]).toBe("premium");
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("a real buyer still gets the plan at their verified first link (#8793 AC-4)", () => {
|
|
238
|
+
const BUYER = "new@x.io";
|
|
239
|
+
const buyer = () => people().find((p) => p["email"] === BUYER)!;
|
|
240
|
+
// A Person the capture created carries no `planOffering` key in the double
|
|
241
|
+
// (Postgres reads the unwritten column back as `null`).
|
|
242
|
+
const buyerPlan = () => buyer()["planOffering"] ?? null;
|
|
243
|
+
|
|
244
|
+
it("the checkout holds, and the buyer's first member read releases it", async () => {
|
|
245
|
+
await routeLead(checkout(BUYER, "wendi_retainer"), sourceMeta, {
|
|
246
|
+
idempotencyKey: KEY,
|
|
247
|
+
});
|
|
248
|
+
expect(buyerPlan()).toBeNull();
|
|
249
|
+
expect(planLines()).toEqual([
|
|
250
|
+
{ event: "checkout-plan", outcome: "held", siteId: WS },
|
|
251
|
+
]);
|
|
252
|
+
verifiedUser(U, BUYER);
|
|
253
|
+
expect(await resolveMemberPersonId(WS, U, BUYER)).toBe(buyer()["id"]);
|
|
254
|
+
expect(buyerPlan()).toBe("retainer");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("a second purchase of the same offering under a new key still releases it", async () => {
|
|
258
|
+
await routeLead(checkout(BUYER, "wendi_retainer"), sourceMeta, {
|
|
259
|
+
idempotencyKey: KEY,
|
|
260
|
+
});
|
|
261
|
+
await routeLead(checkout(BUYER, "wendi_retainer"), sourceMeta, {
|
|
262
|
+
idempotencyKey: KEY_2,
|
|
263
|
+
});
|
|
264
|
+
expect(leads()).toHaveLength(2);
|
|
265
|
+
verifiedUser(U, BUYER);
|
|
266
|
+
expect(await resolveMemberPersonId(WS, U, BUYER)).toBe(buyer()["id"]);
|
|
267
|
+
expect(buyerPlan()).toBe("retainer");
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("the lead, the purchase message, the purchase email and the consent grant are unchanged (#8793 AC-5)", () => {
|
|
272
|
+
it.each([
|
|
273
|
+
["linked with a plan", { userId: U, planOffering: "premium" }],
|
|
274
|
+
["linked with no plan", { userId: U }],
|
|
275
|
+
["unlinked", {}],
|
|
276
|
+
] as const)("victim %s", async (_label, victimRow) => {
|
|
277
|
+
verifiedUser(U, VICTIM);
|
|
278
|
+
seedVictim(victimRow);
|
|
279
|
+
const terms = consent.currentAccountTermsStamp()!;
|
|
280
|
+
const result = await routeLead(
|
|
281
|
+
checkout("VICTIM@x.io", "wendi_basic", { termsAccepted: terms.stamp }),
|
|
282
|
+
sourceMeta,
|
|
283
|
+
{ idempotencyKey: KEY },
|
|
284
|
+
);
|
|
285
|
+
await settle();
|
|
286
|
+
const personId = victim()!["id"];
|
|
287
|
+
expect(result).toEqual({
|
|
288
|
+
ok: true,
|
|
289
|
+
id: leads()[0]!["id"],
|
|
290
|
+
personId,
|
|
291
|
+
persisted: "db",
|
|
292
|
+
email: expect.anything(),
|
|
293
|
+
});
|
|
294
|
+
expect(leads()).toHaveLength(1);
|
|
295
|
+
expect(leads()[0]).toMatchObject({ spokeCheckout: true, personId });
|
|
296
|
+
expect(interactions()).toHaveLength(1);
|
|
297
|
+
expect(interactions()[0]).toMatchObject({
|
|
298
|
+
personId,
|
|
299
|
+
channel: "member",
|
|
300
|
+
direction: "outbound",
|
|
301
|
+
subject: "Your purchase",
|
|
302
|
+
createdBy: "system:checkout",
|
|
303
|
+
});
|
|
304
|
+
expect(mockSendPurchaseConfirmationEmail).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(recordGrant).toHaveBeenCalledTimes(1);
|
|
306
|
+
expect(recordGrant).toHaveBeenCalledWith({
|
|
307
|
+
workspaceId: WS,
|
|
308
|
+
personId,
|
|
309
|
+
sessionId: KEY,
|
|
310
|
+
policyVersion: terms.policyVersion,
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
});
|