create-ailk 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -14
- package/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +17 -10
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +301 -13
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +26 -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 +36 -2
- package/dist/lib/scaffold-render-sweep.js +50 -8
- package/dist/module-architecture.js +115 -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 +68 -5
- package/dist/sync-routes.js +455 -29
- 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 +13 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/CLAUDE.md +4 -2
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +34 -0
- package/templates/apps/api/CLAUDE.md +94 -19
- 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 +7 -1
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +16 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/site.ts +4 -1
- package/templates/apps/api/src/lib/stripe.ts +77 -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/__tests__/openapi.test.ts +127 -0
- package/templates/apps/api/src/openapi/spec.ts +343 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +332 -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 +1426 -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/get-page.ts +34 -119
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -36
- 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/search.ts +16 -45
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +377 -308
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +20 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +17 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +1 -1
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +1 -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 +6 -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 +93 -17
- package/templates/apps/api/src/server.ts +165 -51
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +121 -4
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +61 -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 +260 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +450 -0
- package/templates/apps/api/src/services/__tests__/project-listings.counts.test.ts +202 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +15 -18
- package/templates/apps/api/src/services/abandon-email.ts +28 -5
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +15 -12
- 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 +348 -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 +975 -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 +400 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +685 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +318 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +403 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1083 -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 +147 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/project-listings.ts +43 -31
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +32 -18
- package/templates/apps/mcp/__tests__/app-entry.test.ts +2 -2
- 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 +95 -14
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +8 -3
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +0 -25
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +22 -7
- package/templates/apps/mcp/__tests__/list_pages.test.ts +84 -44
- package/templates/apps/mcp/__tests__/locale.test.ts +41 -56
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/rate-limit-write.test.ts +189 -0
- package/templates/apps/mcp/__tests__/read_content.test.ts +115 -78
- package/templates/apps/mcp/__tests__/request_callback.test.ts +63 -128
- package/templates/apps/mcp/__tests__/search_content.test.ts +76 -61
- 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__/submit_lead.test.ts +82 -167
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +59 -130
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/package.json +1 -0
- package/templates/apps/mcp/src/abuse/index.ts +18 -11
- package/templates/apps/mcp/src/http-transport.ts +47 -9
- package/templates/apps/mcp/src/lib/content-adapter.ts +0 -26
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +5 -4
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/lib/site-config.ts +53 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +30 -28
- 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/index.ts +22 -21
- package/templates/apps/mcp/src/tools/list_pages.ts +27 -33
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/tools/read_content.ts +46 -40
- package/templates/apps/mcp/src/tools/request_callback.ts +21 -28
- package/templates/apps/mcp/src/tools/search_content.ts +20 -24
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +23 -26
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +324 -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__/ConversationalIntakeWrapper.test.tsx +1 -1
- 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 +20 -6
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +1 -1
- 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 +369 -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 +64 -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 +44 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +205 -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 +51 -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 +63 -1
- package/templates/database/__tests__/trial-magnet-migration.test.ts +186 -0
- 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/migrations/20261025120000_trial_magnet/migration.sql +28 -0
- package/templates/database/migrations/20261026120000_trial_magnet_proof_line/migration.sql +23 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +57 -2
- package/templates/tools/lint-architecture.ts +205 -1
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/api/src/routes/content/slug.ts +0 -9
- 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";
|
|
@@ -246,6 +246,11 @@ const WORKSPACES_MESSAGES_OUT = "./routes/workspaces/messages-out.js";
|
|
|
246
246
|
// #8171 D8 — the workspace's Requests read (the inbound member rows), the
|
|
247
247
|
// SAME scope.
|
|
248
248
|
const WORKSPACES_REQUESTS = "./routes/workspaces/requests.js";
|
|
249
|
+
// #9254 DD5 — the workspace's Callbacks read (request-and-confirm rows), the
|
|
250
|
+
// SAME scope.
|
|
251
|
+
const WORKSPACES_CALLBACKS = "./routes/workspaces/callbacks.js";
|
|
252
|
+
// #9287 DD2 — the owner's Start, Get and Extend trial, the SAME scope.
|
|
253
|
+
const WORKSPACES_TRIAL = "./routes/workspaces/trial.js";
|
|
249
254
|
// #7877 D11 — the member reads: auth only, under the SAME module predicate.
|
|
250
255
|
const MEMBER_PIECES = "./routes/member/pieces.js";
|
|
251
256
|
const MEMBER_MESSAGES = "./routes/member/messages.js";
|
|
@@ -289,6 +294,7 @@ const ASSETS_UPLOAD = "./routes/assets/upload.js";
|
|
|
289
294
|
const ASSETS_READ = "./routes/assets/read.js";
|
|
290
295
|
const ASSETS_MANAGE = "./routes/assets/manage.js";
|
|
291
296
|
const BILLING_INDEX = "./routes/billing/index.js";
|
|
297
|
+
const WORKSPACES_OFFERINGS = "./routes/workspaces/offerings.js";
|
|
292
298
|
const WAITLIST_EXPERIMENTS_INDEX = "./routes/waitlist-experiments/index.js";
|
|
293
299
|
const SCHEDULE_INDEX = "./routes/schedule/index.js";
|
|
294
300
|
const FLOWS_INDEX = "./routes/flows/index.js";
|
|
@@ -346,6 +352,11 @@ const SLICE_PLUGINS = {
|
|
|
346
352
|
specifier: WORKSPACES_SUBMISSIONS,
|
|
347
353
|
load: () => import(WORKSPACES_SUBMISSIONS),
|
|
348
354
|
},
|
|
355
|
+
// #9170 — the Inbox orders read, the SAME scope.
|
|
356
|
+
workspaceOrderRoutes: {
|
|
357
|
+
specifier: WORKSPACES_ORDERS,
|
|
358
|
+
load: () => import(WORKSPACES_ORDERS),
|
|
359
|
+
},
|
|
349
360
|
workspaceSubscriberRoutes: {
|
|
350
361
|
specifier: WORKSPACES_SUBSCRIBERS,
|
|
351
362
|
load: () => import(WORKSPACES_SUBSCRIBERS),
|
|
@@ -366,6 +377,16 @@ const SLICE_PLUGINS = {
|
|
|
366
377
|
specifier: WORKSPACES_REQUESTS,
|
|
367
378
|
load: () => import(WORKSPACES_REQUESTS),
|
|
368
379
|
},
|
|
380
|
+
// #9254 DD5 — the Callbacks read, the SAME scope.
|
|
381
|
+
workspaceCallbackRoutes: {
|
|
382
|
+
specifier: WORKSPACES_CALLBACKS,
|
|
383
|
+
load: () => import(WORKSPACES_CALLBACKS),
|
|
384
|
+
},
|
|
385
|
+
// #9287 DD2 — the trial routes, the SAME scope.
|
|
386
|
+
workspaceTrialRoutes: {
|
|
387
|
+
specifier: WORKSPACES_TRIAL,
|
|
388
|
+
load: () => import(WORKSPACES_TRIAL),
|
|
389
|
+
},
|
|
369
390
|
workspacePlanRoutes: {
|
|
370
391
|
specifier: WORKSPACES_PLAN,
|
|
371
392
|
load: () => import(WORKSPACES_PLAN),
|
|
@@ -481,6 +502,11 @@ const SLICE_PLUGINS = {
|
|
|
481
502
|
specifier: BILLING_INDEX,
|
|
482
503
|
load: () => import(BILLING_INDEX),
|
|
483
504
|
},
|
|
505
|
+
// #8926 D1 — the workspace Offerings routes, multi-tenant only.
|
|
506
|
+
workspaceOfferingsRoutes: {
|
|
507
|
+
specifier: WORKSPACES_OFFERINGS,
|
|
508
|
+
load: () => import(WORKSPACES_OFFERINGS),
|
|
509
|
+
},
|
|
484
510
|
waitlistExperimentRoutes: {
|
|
485
511
|
specifier: WAITLIST_EXPERIMENTS_INDEX,
|
|
486
512
|
load: () => import(WAITLIST_EXPERIMENTS_INDEX),
|
|
@@ -504,6 +530,11 @@ const SLICE_PLUGINS = {
|
|
|
504
530
|
specifier: SCHEDULE_INDEX,
|
|
505
531
|
load: () => import(SCHEDULE_INDEX),
|
|
506
532
|
},
|
|
533
|
+
// #8843 D1 — the meetings read; same specifier as its schedule siblings.
|
|
534
|
+
listMeetingsRoute: {
|
|
535
|
+
specifier: SCHEDULE_INDEX,
|
|
536
|
+
load: () => import(SCHEDULE_INDEX),
|
|
537
|
+
},
|
|
507
538
|
// #6085 — the tenant-guarded appointments read; same specifier as the
|
|
508
539
|
// three anonymous schedule routes above (§ D6, no new SLICE_PLUGINS
|
|
509
540
|
// specifier constant needed).
|
|
@@ -539,6 +570,11 @@ const SLICE_PLUGINS = {
|
|
|
539
570
|
specifier: DELIVERABLES_INDEX,
|
|
540
571
|
load: () => import(DELIVERABLES_INDEX),
|
|
541
572
|
},
|
|
573
|
+
// #8848 D5 — an ungated file's bytes, in its own `deliverableopen` scope.
|
|
574
|
+
deliverableOpenRoute: {
|
|
575
|
+
specifier: DELIVERABLES_INDEX,
|
|
576
|
+
load: () => import(DELIVERABLES_INDEX),
|
|
577
|
+
},
|
|
542
578
|
projectListingStartRoute: {
|
|
543
579
|
specifier: PROJECT_LISTINGS_INDEX,
|
|
544
580
|
load: () => import(PROJECT_LISTINGS_INDEX),
|
|
@@ -840,6 +876,17 @@ export async function build() {
|
|
|
840
876
|
(msg) => server.log.warn(msg),
|
|
841
877
|
);
|
|
842
878
|
|
|
879
|
+
// #8805 — the sign-up evidence read's site key, verified once per request
|
|
880
|
+
// and shared by the global ceiling and the `signupevidence` scope below. A
|
|
881
|
+
// registered, unrevoked spoke key (`resolveCallerSite`, the registry lookup
|
|
882
|
+
// every spoke-aware route uses) is counted per key; a malformed, unknown or
|
|
883
|
+
// revoked one, or none, keeps `req.ip`. It decides the bucket only — the
|
|
884
|
+
// route still serves `resolveSiteWorkspaceId()`.
|
|
885
|
+
const signUpEvidenceSiteKey = siteKeyReader(async (req) => {
|
|
886
|
+
const caller = await resolveCallerSite(req);
|
|
887
|
+
return caller.kind === "spoke" ? caller.spokeId : null;
|
|
888
|
+
});
|
|
889
|
+
|
|
843
890
|
// Collect the route table this server registers (#5314).
|
|
844
891
|
//
|
|
845
892
|
// Installed here — after `Fastify()` and before the first `register()` — so
|
|
@@ -870,6 +917,7 @@ export async function build() {
|
|
|
870
917
|
// so connection strings, hostnames, stack traces, and SDK error details
|
|
871
918
|
// never reach the client. Full detail still goes to pino at error level.
|
|
872
919
|
registerErrorSerializer(server);
|
|
920
|
+
server.setNotFoundHandler(notFoundHandler);
|
|
873
921
|
|
|
874
922
|
// Register CORS — public read API + the trusted browser origins (#7395).
|
|
875
923
|
// `origin` is ALWAYS a copy of `config.TRUSTED_ORIGINS`, the frozen list
|
|
@@ -918,11 +966,23 @@ export async function build() {
|
|
|
918
966
|
// visitor's own ceiling here too, or a spoke whose visitors share one
|
|
919
967
|
// egress IP would still be capped at this limit in total. For every other
|
|
920
968
|
// request it returns `req.ip`, the plugin's own default key.
|
|
969
|
+
//
|
|
970
|
+
// #8805: a sign-up evidence read with a verified site key is keyed
|
|
971
|
+
// `site,<spokeId>` and let through here — its scope's per-key ceiling is
|
|
972
|
+
// its one limit, so an anonymous caller spoofing the web's egress address
|
|
973
|
+
// cannot spend it, and many web instances behind that address share no
|
|
974
|
+
// IP bucket.
|
|
921
975
|
await server.register(rateLimit, {
|
|
922
976
|
max: CONTENT_RATE_LIMIT_UNAUTH,
|
|
923
977
|
timeWindow: CONTENT_RATE_LIMIT_WINDOW,
|
|
924
|
-
keyGenerator: (req) =>
|
|
925
|
-
|
|
978
|
+
keyGenerator: async (req) => {
|
|
979
|
+
const siteId = await signUpEvidenceSiteKey(req);
|
|
980
|
+
return siteId === null
|
|
981
|
+
? resolveRateLimitIdentity(req, visitorForwardKeyDigests).identity
|
|
982
|
+
: `${SITE_KEY_IDENTITY_PREFIX}${siteId}`;
|
|
983
|
+
},
|
|
984
|
+
allowList: (_req, key) =>
|
|
985
|
+
typeof key === "string" && key.startsWith(SITE_KEY_IDENTITY_PREFIX),
|
|
926
986
|
});
|
|
927
987
|
|
|
928
988
|
// Health check
|
|
@@ -1122,6 +1182,10 @@ export async function build() {
|
|
|
1122
1182
|
await tenantScope.register(await sliceRoutePlugin("sessionRoutes"));
|
|
1123
1183
|
// Org-billing routes (#3701) — org-scoped read + Stripe customer-portal.
|
|
1124
1184
|
await tenantScope.register(await sliceRoutePlugin("billingRoutes"));
|
|
1185
|
+
// The workspace Offerings routes (#8926 D1) — the Library → Offerings
|
|
1186
|
+
// tab's read and its selection write, beside the billing routes that
|
|
1187
|
+
// write the Stripe connection they need.
|
|
1188
|
+
await tenantScope.register(await sliceRoutePlugin("workspaceOfferingsRoutes"));
|
|
1125
1189
|
});
|
|
1126
1190
|
}
|
|
1127
1191
|
|
|
@@ -1268,6 +1332,11 @@ export async function build() {
|
|
|
1268
1332
|
await tenantScope.register(
|
|
1269
1333
|
await sliceRoutePlugin("workspaceSubscriberRoutes"),
|
|
1270
1334
|
);
|
|
1335
|
+
// The Inbox orders read (#9170) — the SAME scope and guard: the
|
|
1336
|
+
// workspace's purchases (list, item read), behind `inbox-read`.
|
|
1337
|
+
await tenantScope.register(
|
|
1338
|
+
await sliceRoutePlugin("workspaceOrderRoutes"),
|
|
1339
|
+
);
|
|
1271
1340
|
// The People reads (#852 D8) — the SAME scope and guard: the
|
|
1272
1341
|
// workspace's clients and one Person with its derived timeline,
|
|
1273
1342
|
// behind `people-read` (owner, admin, a granted agent).
|
|
@@ -1286,6 +1355,16 @@ export async function build() {
|
|
|
1286
1355
|
await tenantScope.register(
|
|
1287
1356
|
await sliceRoutePlugin("workspaceRequestRoutes"),
|
|
1288
1357
|
);
|
|
1358
|
+
// #9254 DD5 — the Callbacks read: the request-and-confirm rows the
|
|
1359
|
+
// Inbox lists under Interested, behind `inbox-read`.
|
|
1360
|
+
await tenantScope.register(
|
|
1361
|
+
await sliceRoutePlugin("workspaceCallbackRoutes"),
|
|
1362
|
+
);
|
|
1363
|
+
// #9287 DD2 — Start, Get and Extend trial, behind `billing-manage`
|
|
1364
|
+
// (the owner alone; never an agent).
|
|
1365
|
+
await tenantScope.register(
|
|
1366
|
+
await sliceRoutePlugin("workspaceTrialRoutes"),
|
|
1367
|
+
);
|
|
1289
1368
|
await tenantScope.register(
|
|
1290
1369
|
await sliceRoutePlugin("workspacePlanRoutes"),
|
|
1291
1370
|
);
|
|
@@ -1385,8 +1464,20 @@ export async function build() {
|
|
|
1385
1464
|
await server.register(await sliceRoutePlugin("publicAssetReadRoute"));
|
|
1386
1465
|
}
|
|
1387
1466
|
await server.register(listTypesRoute);
|
|
1388
|
-
// Before `listPagesRoute`: `search`
|
|
1467
|
+
// Before `listPagesRoute`: `search`, `offerings`, `pricing` and
|
|
1468
|
+
// `shown-offerings` must never be read as a `:type`.
|
|
1389
1469
|
await server.register(searchContentRoute);
|
|
1470
|
+
// The Organization's Stripe catalog source (#8922), so a stale snapshot
|
|
1471
|
+
// refreshes on the service's bounded, never-forced path (#8924 D7, #9124;
|
|
1472
|
+
// the bounds are `refreshWorkspaceSnapshot`'s). The two agent twins read
|
|
1473
|
+
// the shown set through the same read with these same options, so all
|
|
1474
|
+
// three refresh together (#8933 D5).
|
|
1475
|
+
const shownSetRouteOptions: ShownOfferingsRouteOptions = {
|
|
1476
|
+
sourceFor: stripeCatalogSourceFor,
|
|
1477
|
+
};
|
|
1478
|
+
await server.register(offeringsRoute, shownSetRouteOptions);
|
|
1479
|
+
await server.register(pricingRoute, shownSetRouteOptions);
|
|
1480
|
+
await server.register(shownOfferingsRoute, shownSetRouteOptions);
|
|
1390
1481
|
await server.register(listPagesRoute);
|
|
1391
1482
|
await server.register(getPageRoute);
|
|
1392
1483
|
// The hub's sign-in page names the spoke that sent a visitor (#8790 D2):
|
|
@@ -1471,6 +1562,20 @@ export async function build() {
|
|
|
1471
1562
|
// (signal-collectors, D-48): gated PDFs and reports.
|
|
1472
1563
|
if (isModuleEnabled("signal-collectors")) {
|
|
1473
1564
|
await server.register(await sliceRoutePlugin("deliverableDownloadRoute"));
|
|
1565
|
+
// #8848 D5 — the ungated file read, anonymous and keyed by slug, so it
|
|
1566
|
+
// gets its OWN encapsulated scope: `rateLimitMiddleware` breaks
|
|
1567
|
+
// encapsulation, so it is never installed on the grant route's
|
|
1568
|
+
// registration above, whose budget stays as it is. With the hub's
|
|
1569
|
+
// forward-key digests (the route is on FORWARDED_VISITOR_ROUTES), a
|
|
1570
|
+
// site's proxied reads are counted per visitor (SEC-8848-C).
|
|
1571
|
+
await server.register(async (deliverableOpenScope) => {
|
|
1572
|
+
deliverableOpenScope.register(rateLimitMiddleware, {
|
|
1573
|
+
perIpPerMinute: 60,
|
|
1574
|
+
scope: "deliverableopen",
|
|
1575
|
+
trustedKeyDigests: visitorForwardKeyDigests,
|
|
1576
|
+
});
|
|
1577
|
+
await deliverableOpenScope.register(await sliceRoutePlugin("deliverableOpenRoute"));
|
|
1578
|
+
});
|
|
1474
1579
|
}
|
|
1475
1580
|
|
|
1476
1581
|
// Webhooks routes — raw-body parser scoped to this sub-router only so the
|
|
@@ -1586,15 +1691,20 @@ export async function build() {
|
|
|
1586
1691
|
await unsubscribeScope.register(consentUnsubscribeRoute);
|
|
1587
1692
|
});
|
|
1588
1693
|
// #8765 D2 — the sign-up agreements' evidence, beside the consent floor:
|
|
1589
|
-
// anonymous,
|
|
1590
|
-
//
|
|
1591
|
-
//
|
|
1592
|
-
//
|
|
1593
|
-
//
|
|
1694
|
+
// anonymous, outside every module gate (sign-up and consent are never-gate
|
|
1695
|
+
// surfaces). Its own scope, `signupevidence`, so an anonymous burst never
|
|
1696
|
+
// shares a bucket with the cookie banner's log (SEC-8765-D). Its main
|
|
1697
|
+
// caller is the web's own server, whose cache keeps it to one call a minute
|
|
1698
|
+
// per enabled locale per instance. #8805: a call with a verified site key
|
|
1699
|
+
// is counted per key (DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
|
|
1700
|
+
// AILK_RATE_LIMIT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE), never per IP; any
|
|
1701
|
+
// other call keeps 10 a minute per IP.
|
|
1594
1702
|
await server.register(async (signUpEvidenceScope) => {
|
|
1595
1703
|
signUpEvidenceScope.register(rateLimitMiddleware, {
|
|
1596
1704
|
perIpPerMinute: DEFAULT_PER_IP_PER_MINUTE_STRICT,
|
|
1705
|
+
perKeyPerMinute: DEFAULT_SIGNUPEVIDENCE_PER_KEY_PER_MINUTE,
|
|
1597
1706
|
scope: "signupevidence",
|
|
1707
|
+
siteKeyOf: signUpEvidenceSiteKey,
|
|
1598
1708
|
});
|
|
1599
1709
|
await signUpEvidenceScope.register(consentSignUpRoute);
|
|
1600
1710
|
});
|
|
@@ -1772,6 +1882,10 @@ export async function build() {
|
|
|
1772
1882
|
await scheduleScope.register(
|
|
1773
1883
|
await sliceRoutePlugin("meetingTypesRoute"),
|
|
1774
1884
|
);
|
|
1885
|
+
// #8843 D1 — the meetings read: the same gate and per-IP limit.
|
|
1886
|
+
await scheduleScope.register(
|
|
1887
|
+
await sliceRoutePlugin("listMeetingsRoute"),
|
|
1888
|
+
);
|
|
1775
1889
|
});
|
|
1776
1890
|
}
|
|
1777
1891
|
|
|
@@ -29,7 +29,16 @@ jest.mock("@working-theory/observability", () => ({
|
|
|
29
29
|
}));
|
|
30
30
|
|
|
31
31
|
import { prisma } from "../../lib/prisma.js";
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
ABANDON_CANDIDATE_PAGE_SIZE,
|
|
34
|
+
runAbandonEmailSweep,
|
|
35
|
+
} from "../abandon-email";
|
|
36
|
+
|
|
37
|
+
// `flowSession.updateMany` — the dark-mode batch stamp (#9249) — is not on
|
|
38
|
+
// the shared double; installed here the way lead-routing.test.ts installs
|
|
39
|
+
// `lead.count`.
|
|
40
|
+
const mockFlowSessionUpdateMany = jest.fn();
|
|
41
|
+
Object.assign(prisma.flowSession, { updateMany: mockFlowSessionUpdateMany });
|
|
33
42
|
|
|
34
43
|
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
35
44
|
|
|
@@ -183,15 +192,16 @@ describe("runAbandonEmailSweep", () => {
|
|
|
183
192
|
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue([
|
|
184
193
|
sessionRow(),
|
|
185
194
|
]);
|
|
186
|
-
|
|
195
|
+
mockFlowSessionUpdateMany.mockResolvedValue({ count: 1 });
|
|
187
196
|
|
|
188
197
|
const result = await runAbandonEmailSweep({ now: NOW });
|
|
189
198
|
|
|
190
199
|
expect(mockSendAbandonEmail).not.toHaveBeenCalled();
|
|
191
|
-
expect(
|
|
192
|
-
where: { id: SESSION_ID },
|
|
200
|
+
expect(mockFlowSessionUpdateMany).toHaveBeenCalledWith({
|
|
201
|
+
where: { id: { in: [SESSION_ID] }, abandonEmailStatus: null },
|
|
193
202
|
data: { abandonEmailStatus: "dark", abandonEmailAt: NOW },
|
|
194
203
|
});
|
|
204
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
195
205
|
expect(result).toMatchObject({
|
|
196
206
|
ok: true,
|
|
197
207
|
summary: {
|
|
@@ -205,6 +215,113 @@ describe("runAbandonEmailSweep", () => {
|
|
|
205
215
|
});
|
|
206
216
|
});
|
|
207
217
|
|
|
218
|
+
// ── #9249 — dark mode stamps the whole candidate set in ONE updateMany ──
|
|
219
|
+
|
|
220
|
+
it("dark mode stamps N≥3 candidates with exactly one flowSession.updateMany, never a per-row update", async () => {
|
|
221
|
+
const ids = ["flsess_a", "flsess_b", "flsess_c"];
|
|
222
|
+
(prisma.flowDefinition.findMany as jest.Mock).mockResolvedValue([
|
|
223
|
+
flowDefRow({
|
|
224
|
+
abandonEmail: {
|
|
225
|
+
mode: "dark",
|
|
226
|
+
delayMinutes: 30,
|
|
227
|
+
copy: { subject: en("Still interested?"), body: en("Come back!") },
|
|
228
|
+
},
|
|
229
|
+
}),
|
|
230
|
+
]);
|
|
231
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue(
|
|
232
|
+
ids.map((id) => sessionRow({ id })),
|
|
233
|
+
);
|
|
234
|
+
mockFlowSessionUpdateMany.mockResolvedValue({ count: ids.length });
|
|
235
|
+
|
|
236
|
+
const result = await runAbandonEmailSweep({ now: NOW });
|
|
237
|
+
|
|
238
|
+
expect(mockFlowSessionUpdateMany).toHaveBeenCalledTimes(1);
|
|
239
|
+
expect(mockFlowSessionUpdateMany).toHaveBeenCalledWith({
|
|
240
|
+
// The `abandonEmailStatus: null` guard keeps a row stamped concurrently
|
|
241
|
+
// from being re-stamped — the same term the candidate read selects on.
|
|
242
|
+
where: { id: { in: ids }, abandonEmailStatus: null },
|
|
243
|
+
data: { abandonEmailStatus: "dark", abandonEmailAt: NOW },
|
|
244
|
+
});
|
|
245
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
246
|
+
expect(mockSendAbandonEmail).not.toHaveBeenCalled();
|
|
247
|
+
// Per-candidate audit logging is unchanged — one line per session.
|
|
248
|
+
expect(
|
|
249
|
+
mockLoggerInfo.mock.calls.filter(
|
|
250
|
+
([fields]) => fields.outcome === "dark" && fields.mode === "dark",
|
|
251
|
+
),
|
|
252
|
+
).toHaveLength(ids.length);
|
|
253
|
+
// Summary counters report the same numbers as the per-row stamps did.
|
|
254
|
+
expect(result).toEqual({
|
|
255
|
+
ok: true,
|
|
256
|
+
summary: { scanned: 3, selected: 3, dark: 3, sent: 0, failed: 0, skipped: 0 },
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("dark mode with no candidates issues no updateMany at all", async () => {
|
|
261
|
+
(prisma.flowDefinition.findMany as jest.Mock).mockResolvedValue([
|
|
262
|
+
flowDefRow({
|
|
263
|
+
abandonEmail: {
|
|
264
|
+
mode: "dark",
|
|
265
|
+
delayMinutes: 30,
|
|
266
|
+
copy: { subject: en("Still interested?"), body: en("Come back!") },
|
|
267
|
+
},
|
|
268
|
+
}),
|
|
269
|
+
]);
|
|
270
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue([]);
|
|
271
|
+
|
|
272
|
+
const result = await runAbandonEmailSweep({ now: NOW });
|
|
273
|
+
|
|
274
|
+
expect(mockFlowSessionUpdateMany).not.toHaveBeenCalled();
|
|
275
|
+
expect(prisma.flowSession.update).not.toHaveBeenCalled();
|
|
276
|
+
expect(result).toEqual({
|
|
277
|
+
ok: true,
|
|
278
|
+
summary: { scanned: 0, selected: 0, dark: 0, sent: 0, failed: 0, skipped: 0 },
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ── #9249 — the candidate read is paged ──────────────────────────────────
|
|
283
|
+
|
|
284
|
+
it("pages the candidate read with take: ABANDON_CANDIDATE_PAGE_SIZE and processes exactly one page per run", async () => {
|
|
285
|
+
expect(ABANDON_CANDIDATE_PAGE_SIZE).toBeGreaterThan(0);
|
|
286
|
+
(prisma.flowDefinition.findMany as jest.Mock).mockResolvedValue([
|
|
287
|
+
flowDefRow({
|
|
288
|
+
abandonEmail: {
|
|
289
|
+
mode: "dark",
|
|
290
|
+
delayMinutes: 30,
|
|
291
|
+
copy: { subject: en("Still interested?"), body: en("Come back!") },
|
|
292
|
+
},
|
|
293
|
+
}),
|
|
294
|
+
]);
|
|
295
|
+
// The database honours `take`: a run with more candidates than the page
|
|
296
|
+
// sees exactly one page of them. The rest stay `abandonEmailStatus: null`
|
|
297
|
+
// and are selected by the next run.
|
|
298
|
+
const page = Array.from({ length: ABANDON_CANDIDATE_PAGE_SIZE }, (_, i) =>
|
|
299
|
+
sessionRow({ id: `flsess_${i}` }),
|
|
300
|
+
);
|
|
301
|
+
(prisma.flowSession.findMany as jest.Mock).mockResolvedValue(page);
|
|
302
|
+
mockFlowSessionUpdateMany.mockResolvedValue({ count: page.length });
|
|
303
|
+
|
|
304
|
+
const result = await runAbandonEmailSweep({ now: NOW });
|
|
305
|
+
|
|
306
|
+
expect(prisma.flowSession.findMany).toHaveBeenCalledTimes(1);
|
|
307
|
+
expect(prisma.flowSession.findMany).toHaveBeenCalledWith(
|
|
308
|
+
expect.objectContaining({ take: ABANDON_CANDIDATE_PAGE_SIZE }),
|
|
309
|
+
);
|
|
310
|
+
// No second read within the run — the remainder is the next run's.
|
|
311
|
+
expect(mockFlowSessionUpdateMany).toHaveBeenCalledTimes(1);
|
|
312
|
+
expect(mockFlowSessionUpdateMany.mock.calls[0][0].where.id.in).toHaveLength(
|
|
313
|
+
ABANDON_CANDIDATE_PAGE_SIZE,
|
|
314
|
+
);
|
|
315
|
+
expect(result).toMatchObject({
|
|
316
|
+
ok: true,
|
|
317
|
+
summary: {
|
|
318
|
+
scanned: ABANDON_CANDIDATE_PAGE_SIZE,
|
|
319
|
+
selected: ABANDON_CANDIDATE_PAGE_SIZE,
|
|
320
|
+
dark: ABANDON_CANDIDATE_PAGE_SIZE,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
208
325
|
// ── AC2 — enabled mode: selected + sent ──────────────────────────────────
|
|
209
326
|
|
|
210
327
|
it("enabled mode calls sendAbandonEmail and stamps abandonEmailStatus:'sent'", async () => {
|