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
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
// Downloads list page. Reads DownloadsList MDX via renderPage and emits
|
|
2
|
-
// a JSON-LD CollectionPage via the dispatcher. List-only — no detail route.
|
|
3
|
-
import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
|
|
4
|
-
import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
|
|
5
|
-
import type { Metadata } from "next";
|
|
6
|
-
import type { ReactElement } from "react";
|
|
7
|
-
|
|
8
|
-
interface Props {
|
|
9
|
-
params: Promise<{ locale: string }>;
|
|
10
|
-
getMdxSource?: typeof defaultGetMdxSource;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export async function generateMetadata({
|
|
14
|
-
params,
|
|
15
|
-
}: Pick<Props, "params">): Promise<Metadata> {
|
|
16
|
-
const { locale } = await params;
|
|
17
|
-
return renderPageMetadata({
|
|
18
|
-
type: "DownloadsList",
|
|
19
|
-
slug: "downloads",
|
|
20
|
-
locale,
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export default async function Page({
|
|
25
|
-
params,
|
|
26
|
-
getMdxSource,
|
|
27
|
-
}: Props): Promise<ReactElement> {
|
|
28
|
-
const { locale } = await params;
|
|
29
|
-
return renderPage({
|
|
30
|
-
type: "DownloadsList",
|
|
31
|
-
slug: "downloads",
|
|
32
|
-
locale,
|
|
33
|
-
translationsNamespace: "downloads",
|
|
34
|
-
bodyMode: "mdx-body",
|
|
35
|
-
...(getMdxSource !== undefined && { getMdxSource }),
|
|
36
|
-
});
|
|
37
|
-
}
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* subscribeNewsletterAction (#6730 D9) — the Server Action twin of the
|
|
3
|
-
* `ailkSubscribeNewsletter` MCP tool.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { subscribeNewsletterAction } from "../newsletter.actions.js";
|
|
7
|
-
|
|
8
|
-
// `visitorForwardHeaders` stays real (#7394) so AC-9 compares the action's
|
|
9
|
-
// `init` with the helper's own output.
|
|
10
|
-
jest.mock("@working-theory/api-client", () => ({
|
|
11
|
-
leads: { subscribe: jest.fn() },
|
|
12
|
-
visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
|
|
13
|
-
.visitorForwardHeaders,
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
// next/headers' `headers()` is request-scoped in Next; outside it, it is
|
|
17
|
-
// mocked here the way flow.actions.test.ts does (#7394 AC-9).
|
|
18
|
-
jest.mock("next/headers", () => ({
|
|
19
|
-
__esModule: true,
|
|
20
|
-
headers: jest.fn().mockResolvedValue({ get: () => null }),
|
|
21
|
-
}));
|
|
22
|
-
|
|
23
|
-
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
24
|
-
import { headers } from "next/headers";
|
|
25
|
-
const mockSubscribe = leads.subscribe as jest.Mock;
|
|
26
|
-
const mockHeaders = headers as jest.Mock;
|
|
27
|
-
|
|
28
|
-
function formWith(email: string): FormData {
|
|
29
|
-
const fd = new FormData();
|
|
30
|
-
fd.append("email", email);
|
|
31
|
-
fd.append("consent", "true");
|
|
32
|
-
return fd;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
jest.clearAllMocks();
|
|
37
|
-
mockHeaders.mockResolvedValue({ get: () => null });
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
describe("subscribeNewsletterAction", () => {
|
|
41
|
-
it("calls leads.subscribe with source newsletter and the address", async () => {
|
|
42
|
-
mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
|
|
43
|
-
|
|
44
|
-
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
45
|
-
|
|
46
|
-
expect(result).toEqual({ kind: "success" });
|
|
47
|
-
expect(mockSubscribe).toHaveBeenCalledWith(
|
|
48
|
-
expect.objectContaining({ source: "newsletter", email: "ada@example.com" }),
|
|
49
|
-
);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("an invalid address → a field error, no API call", async () => {
|
|
53
|
-
const result = await subscribeNewsletterAction(null, formWith("not-an-email"));
|
|
54
|
-
|
|
55
|
-
expect(result).toMatchObject({ kind: "error", fieldErrors: { email: expect.any(String) } });
|
|
56
|
-
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("503 lead_capture_not_configured → unavailable", async () => {
|
|
60
|
-
mockSubscribe.mockResolvedValueOnce({
|
|
61
|
-
ok: false,
|
|
62
|
-
status: 503,
|
|
63
|
-
body: { error: "lead_capture_not_configured" },
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
67
|
-
|
|
68
|
-
expect(result).toEqual({ kind: "unavailable" });
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("any other failure → the generic error, never success", async () => {
|
|
72
|
-
mockSubscribe.mockResolvedValueOnce({ ok: false, status: 503, body: { error: "persist_failed" } });
|
|
73
|
-
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toMatchObject({
|
|
74
|
-
kind: "error",
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
mockSubscribe.mockRejectedValueOnce(new Error("ECONNREFUSED"));
|
|
78
|
-
jest.spyOn(console, "error").mockImplementationOnce(() => undefined);
|
|
79
|
-
expect(await subscribeNewsletterAction(null, formWith("a@b.co"))).toMatchObject({
|
|
80
|
-
kind: "error",
|
|
81
|
-
});
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("a filled honeypot → success, and nothing is sent", async () => {
|
|
85
|
-
const fd = formWith("ada@example.com");
|
|
86
|
-
fd.append("ailk_hp", "spam");
|
|
87
|
-
|
|
88
|
-
expect(await subscribeNewsletterAction(null, fd)).toEqual({ kind: "success" });
|
|
89
|
-
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
90
|
-
});
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
// #7394 AC-9 — a spoke forwards its visitor to the hub's leads rate limit.
|
|
94
|
-
describe("subscribeNewsletterAction — visitor forwarding (#7394)", () => {
|
|
95
|
-
const FORWARD_KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
|
|
96
|
-
const ENV_KEYS = ["AILK_VISITOR_FORWARD_KEY", "AILK_API_URL"] as const;
|
|
97
|
-
const priorEnv = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
98
|
-
|
|
99
|
-
beforeEach(() => {
|
|
100
|
-
process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
|
|
101
|
-
process.env.AILK_API_URL = "https://hub.example";
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
afterAll(() => {
|
|
105
|
-
for (const [name, value] of priorEnv) {
|
|
106
|
-
if (value === undefined) delete process.env[name];
|
|
107
|
-
else process.env[name] = value;
|
|
108
|
-
}
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("passes the helper's two headers as init", async () => {
|
|
112
|
-
const incoming = {
|
|
113
|
-
get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.5" : null),
|
|
114
|
-
};
|
|
115
|
-
mockHeaders.mockResolvedValue(incoming);
|
|
116
|
-
mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
|
|
117
|
-
|
|
118
|
-
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
119
|
-
|
|
120
|
-
expect(result).toEqual({ kind: "success" });
|
|
121
|
-
const expected = visitorForwardHeaders(incoming, FORWARD_KEY);
|
|
122
|
-
expect(expected).toEqual({
|
|
123
|
-
"x-ailk-visitor-ip": "203.0.113.5",
|
|
124
|
-
"x-ailk-visitor-forward-key": FORWARD_KEY,
|
|
125
|
-
});
|
|
126
|
-
expect(mockSubscribe).toHaveBeenCalledWith(expect.any(Object), { headers: expected });
|
|
127
|
-
});
|
|
128
|
-
|
|
129
|
-
it("calls leads.subscribe with one argument when headers() throws, and still succeeds", async () => {
|
|
130
|
-
mockHeaders.mockRejectedValue(new Error("outside a request scope"));
|
|
131
|
-
mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
|
|
132
|
-
|
|
133
|
-
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
134
|
-
|
|
135
|
-
expect(result).toEqual({ kind: "success" });
|
|
136
|
-
expect(mockSubscribe).toHaveBeenCalledTimes(1);
|
|
137
|
-
expect(mockSubscribe.mock.calls[0]).toHaveLength(1);
|
|
138
|
-
});
|
|
139
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
"use server";
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* subscribeNewsletterAction — Server Action paired with the
|
|
5
|
-
* `ailkSubscribeNewsletter` MCP tool (#6730 D9).
|
|
6
|
-
*
|
|
7
|
-
* `/newsletter` renders the shared `LeadCaptureForm` bound to this action
|
|
8
|
-
* when the API accepts a subscription (a database is set up) and the page's
|
|
9
|
-
* content declares no signup section of its own. Validates the address with
|
|
10
|
-
* the tool's own input schema, calls `POST /v1/leads/subscribe` through
|
|
11
|
-
* `@working-theory/api-client`, and maps the outcome the same way
|
|
12
|
-
* `captureLeadAction` does: the API's 503 `lead_capture_not_configured` →
|
|
13
|
-
* `unavailable`; any other non-2xx or thrown error → the generic `error`;
|
|
14
|
-
* only a 2xx is `success`.
|
|
15
|
-
*
|
|
16
|
-
* Visitor forwarding (#7394): forwards the visitor exactly as
|
|
17
|
-
* `captureLeadAction` does, through `visitorForwardHeaders` and the
|
|
18
|
-
* server-only `AILK_VISITOR_FORWARD_KEY`; without them the call is made
|
|
19
|
-
* with one argument, as before.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
23
|
-
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
24
|
-
import { subscribeNewsletterInputSchema } from "@working-theory/validation";
|
|
25
|
-
import { cookies, headers } from "next/headers";
|
|
26
|
-
|
|
27
|
-
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
28
|
-
|
|
29
|
-
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
30
|
-
|
|
31
|
-
export async function subscribeNewsletterAction(
|
|
32
|
-
_prevState: LeadCaptureFormState,
|
|
33
|
-
formData: FormData,
|
|
34
|
-
): Promise<LeadCaptureFormState> {
|
|
35
|
-
// Honeypot: a bot that fills the hidden field gets a person's answer, and
|
|
36
|
-
// nothing is sent.
|
|
37
|
-
const honeypot = formData.get(LEAD_CAPTURE_HONEYPOT);
|
|
38
|
-
if (typeof honeypot === "string" && honeypot !== "") return { kind: "success" };
|
|
39
|
-
|
|
40
|
-
const parsed = subscribeNewsletterInputSchema.safeParse({
|
|
41
|
-
email: formData.get("email"),
|
|
42
|
-
});
|
|
43
|
-
if (!parsed.success) {
|
|
44
|
-
return {
|
|
45
|
-
kind: "error",
|
|
46
|
-
fieldErrors: { email: parsed.error.issues[0]?.message ?? "Invalid email" },
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const siteId = process.env.AILK_SITE_ID ?? "default";
|
|
51
|
-
const body = { siteId, source: "newsletter", email: parsed.data.email };
|
|
52
|
-
let init: { headers: Record<string, string> } | undefined;
|
|
53
|
-
try {
|
|
54
|
-
const forwarded = visitorForwardHeaders(
|
|
55
|
-
await headers(),
|
|
56
|
-
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
57
|
-
);
|
|
58
|
-
if (forwarded) init = { headers: forwarded };
|
|
59
|
-
} catch {
|
|
60
|
-
// No request scope to read — subscribe without forwarding.
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// #8438 D12 — the banner's visitor id, so a later subscribe links its
|
|
64
|
-
// cookie choices to the client record. Absent or invalid → no header.
|
|
65
|
-
try {
|
|
66
|
-
const visitorId = readConsentVisitor(await cookies());
|
|
67
|
-
if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
|
|
68
|
-
} catch {
|
|
69
|
-
// No request scope to read — subscribe with no visitor-link header.
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
const res = init
|
|
74
|
-
? await leads.subscribe(body, init)
|
|
75
|
-
: await leads.subscribe(body);
|
|
76
|
-
if (!res.ok) {
|
|
77
|
-
const error =
|
|
78
|
-
typeof res.body === "object" && res.body !== null
|
|
79
|
-
? (res.body as { error?: unknown }).error
|
|
80
|
-
: undefined;
|
|
81
|
-
if (res.status === 503 && error === "lead_capture_not_configured") {
|
|
82
|
-
return { kind: "unavailable" };
|
|
83
|
-
}
|
|
84
|
-
return { kind: "error", message: GENERIC_ERROR };
|
|
85
|
-
}
|
|
86
|
-
return { kind: "success" };
|
|
87
|
-
} catch (err) {
|
|
88
|
-
// The api-client never attaches the request payload to a thrown error,
|
|
89
|
-
// so logging `err` carries no subscriber address.
|
|
90
|
-
console.error("[subscribeNewsletterAction]", err);
|
|
91
|
-
return { kind: "error", message: GENERIC_ERROR };
|
|
92
|
-
}
|
|
93
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// The buy route — the standard spoke checkout (#8748), kept current by
|
|
2
|
-
// `create-ailk update`: do not edit it. Every guarantee lives in the
|
|
3
|
-
// spoke-checkout package and arrives by a package bump. This site's offerings
|
|
4
|
-
// are data in lib/checkout-config.ts, the one file to edit. It answers 404 on
|
|
5
|
-
// any build that is not a spoke.
|
|
6
|
-
//
|
|
7
|
-
// A buyer who is signed in when checkout starts returns to /account; everyone
|
|
8
|
-
// else returns to the thank-you page with Stripe's session id (#8757 D12). The
|
|
9
|
-
// session is read through the site's generated seam: Better Auth on a site,
|
|
10
|
-
// the sealed hub session on a hub-sign-in spoke, none without sign-in.
|
|
11
|
-
import { createSpokeCheckout } from "@working-theory/spoke-checkout";
|
|
12
|
-
|
|
13
|
-
import {
|
|
14
|
-
checkoutNotServed,
|
|
15
|
-
checkoutServed,
|
|
16
|
-
spokeCheckoutConfig,
|
|
17
|
-
} from "../../../../lib/checkout";
|
|
18
|
-
import { readSession } from "../../../../lib/topology.generated";
|
|
19
|
-
|
|
20
|
-
export const runtime = "nodejs";
|
|
21
|
-
export const dynamic = "force-dynamic";
|
|
22
|
-
|
|
23
|
-
type BuyRoute = ReturnType<typeof createSpokeCheckout>["buy"];
|
|
24
|
-
type BuyContext = Parameters<BuyRoute["GET"]>[1];
|
|
25
|
-
|
|
26
|
-
let buy: BuyRoute | undefined;
|
|
27
|
-
|
|
28
|
-
/** `/account` for a request with a session, else `null` (the thank-you page). */
|
|
29
|
-
async function successPath(request: Request): Promise<string | null> {
|
|
30
|
-
return (await readSession(request.headers)) ? "/account" : null;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Built on the first served request and kept, so the package's rate limiters persist. */
|
|
34
|
-
function buyRoute(): BuyRoute {
|
|
35
|
-
buy ??= createSpokeCheckout(spokeCheckoutConfig, { successPath }).buy;
|
|
36
|
-
return buy;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// platform-decisions-waiver: api-versioning — a browser buy link that redirects to Stripe Checkout, not a versioned API contract (spec 8748 §7)
|
|
40
|
-
export async function GET(request: Request, ctx: BuyContext): Promise<Response> {
|
|
41
|
-
if (!checkoutServed()) return checkoutNotServed();
|
|
42
|
-
return buyRoute().GET(request, ctx);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export async function POST(request: Request, ctx: BuyContext): Promise<Response> {
|
|
46
|
-
if (!checkoutServed()) return checkoutNotServed();
|
|
47
|
-
return buyRoute().POST(request, ctx);
|
|
48
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment node
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The standard buy route (#8748 D1, D11; spec
|
|
7
|
-
* docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2, AC-4).
|
|
8
|
-
*
|
|
9
|
-
* The checkout package is mocked: every guarantee is its own, proven by its
|
|
10
|
-
* own suites. These cases prove the route is wiring only — built once from
|
|
11
|
-
* `spokeCheckoutConfig`, serving the package's `buy` handlers on a spoke and
|
|
12
|
-
* a 404 anywhere else — and that it declares no offering and no Stripe
|
|
13
|
-
* client of its own.
|
|
14
|
-
*/
|
|
15
|
-
import { readFileSync } from "node:fs";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
|
|
18
|
-
const mockBuyGet = jest.fn();
|
|
19
|
-
const mockBuyPost = jest.fn();
|
|
20
|
-
const mockCreateSpokeCheckout = jest.fn((_config: unknown) => ({
|
|
21
|
-
buy: { GET: mockBuyGet, POST: mockBuyPost },
|
|
22
|
-
}));
|
|
23
|
-
// `virtual`: CI runs this suite before the package's dist is built, so it
|
|
24
|
-
// may not resolve at all — the factory stands in either way.
|
|
25
|
-
jest.mock(
|
|
26
|
-
"@working-theory/spoke-checkout",
|
|
27
|
-
() => ({
|
|
28
|
-
__esModule: true,
|
|
29
|
-
createSpokeCheckout: (config: unknown) => mockCreateSpokeCheckout(config),
|
|
30
|
-
}),
|
|
31
|
-
{ virtual: true },
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
|
|
35
|
-
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
36
|
-
jest.mock("../../../../lib/topology.generated", () => ({
|
|
37
|
-
__esModule: true,
|
|
38
|
-
get TOPOLOGY() {
|
|
39
|
-
return mockTopology.value;
|
|
40
|
-
},
|
|
41
|
-
}));
|
|
42
|
-
|
|
43
|
-
import { checkoutConfig } from "../../../../lib/checkout-config";
|
|
44
|
-
import { spokeCheckoutConfig } from "../../../../lib/checkout";
|
|
45
|
-
import { GET, POST } from "../[offering]/route";
|
|
46
|
-
|
|
47
|
-
const ROUTE_SOURCE = readFileSync(
|
|
48
|
-
join(__dirname, "..", "[offering]", "route.ts"),
|
|
49
|
-
"utf8",
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
function ctxFor(offering: string) {
|
|
53
|
-
return { params: Promise.resolve({ offering }) };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function request(method: "GET" | "POST"): Request {
|
|
57
|
-
return new Request("https://acme.example.com/api/checkout/example", {
|
|
58
|
-
method,
|
|
59
|
-
});
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
describe("the standard buy route (#8748 AC-2)", () => {
|
|
63
|
-
beforeEach(() => {
|
|
64
|
-
mockBuyGet.mockReset();
|
|
65
|
-
mockBuyPost.mockReset();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("answers 404 with no-store on a hub, and never builds the package's handlers (D11)", async () => {
|
|
69
|
-
mockTopology.value = "hub";
|
|
70
|
-
for (const res of [
|
|
71
|
-
await GET(request("GET"), ctxFor("example")),
|
|
72
|
-
await POST(request("POST"), ctxFor("example")),
|
|
73
|
-
]) {
|
|
74
|
-
expect(res.status).toBe(404);
|
|
75
|
-
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
76
|
-
}
|
|
77
|
-
expect(mockCreateSpokeCheckout).not.toHaveBeenCalled();
|
|
78
|
-
expect(mockBuyGet).not.toHaveBeenCalled();
|
|
79
|
-
expect(mockBuyPost).not.toHaveBeenCalled();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("serves the package's buy handlers on a spoke, built once from spokeCheckoutConfig", async () => {
|
|
83
|
-
mockTopology.value = "spoke";
|
|
84
|
-
const fromGet = new Response(null, { status: 303 });
|
|
85
|
-
const fromPost = new Response(null, { status: 303 });
|
|
86
|
-
mockBuyGet.mockResolvedValue(fromGet);
|
|
87
|
-
mockBuyPost.mockResolvedValue(fromPost);
|
|
88
|
-
|
|
89
|
-
const getRequest = request("GET");
|
|
90
|
-
const getCtx = ctxFor("example");
|
|
91
|
-
expect(await GET(getRequest, getCtx)).toBe(fromGet);
|
|
92
|
-
expect(mockBuyGet).toHaveBeenCalledWith(getRequest, getCtx);
|
|
93
|
-
|
|
94
|
-
const postRequest = request("POST");
|
|
95
|
-
const postCtx = ctxFor("example");
|
|
96
|
-
expect(await POST(postRequest, postCtx)).toBe(fromPost);
|
|
97
|
-
expect(mockBuyPost).toHaveBeenCalledWith(postRequest, postCtx);
|
|
98
|
-
|
|
99
|
-
expect(mockCreateSpokeCheckout).toHaveBeenCalledTimes(1);
|
|
100
|
-
const [config] = mockCreateSpokeCheckout.mock.calls[0] as [
|
|
101
|
-
typeof spokeCheckoutConfig,
|
|
102
|
-
];
|
|
103
|
-
expect(config).toBe(spokeCheckoutConfig);
|
|
104
|
-
expect(config.offerings).toBe(checkoutConfig.offerings);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it("names no offering of the site's own", () => {
|
|
108
|
-
for (const slug of Object.keys(checkoutConfig.offerings)) {
|
|
109
|
-
const quoted = new RegExp(`["'\`]${slug}["'\`]`);
|
|
110
|
-
const property = new RegExp(`\\b${slug}\\s*:`);
|
|
111
|
-
expect(ROUTE_SOURCE).not.toMatch(quoted);
|
|
112
|
-
expect(ROUTE_SOURCE).not.toMatch(property);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
|
|
117
|
-
expect(ROUTE_SOURCE).not.toContain("STRIPE_");
|
|
118
|
-
expect(ROUTE_SOURCE).not.toContain("stripe-client");
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment node
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The buy route's success path (#8757 D12, AC-10): a buyer signed in when
|
|
7
|
-
* checkout starts returns to `/account`, everyone else to the thank-you page.
|
|
8
|
-
*
|
|
9
|
-
* The route hands the checkout package a `successPath` hook built on the
|
|
10
|
-
* site's generated session seam (`readSession` in lib/topology.generated —
|
|
11
|
-
* Better Auth on a site, the sealed hub session on a hub-sign-in spoke, none
|
|
12
|
-
* on a spoke without sign-in). The package is mocked: what it does with the
|
|
13
|
-
* answer is its own suite's (`buy-route.test.ts`). These cases prove the hook
|
|
14
|
-
* answers `"/account"` for a request with a session and `null` without one.
|
|
15
|
-
*/
|
|
16
|
-
const mockCreateSpokeCheckout = jest.fn((_config: unknown, _options?: unknown) => ({
|
|
17
|
-
buy: { GET: jest.fn(async () => new Response(null, { status: 303 })), POST: jest.fn() },
|
|
18
|
-
}));
|
|
19
|
-
// `virtual`: CI runs this suite before the package's dist is built.
|
|
20
|
-
jest.mock(
|
|
21
|
-
"@working-theory/spoke-checkout",
|
|
22
|
-
() => ({
|
|
23
|
-
__esModule: true,
|
|
24
|
-
createSpokeCheckout: (config: unknown, options?: unknown) =>
|
|
25
|
-
mockCreateSpokeCheckout(config, options),
|
|
26
|
-
}),
|
|
27
|
-
{ virtual: true },
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
const mockReadSession = jest.fn();
|
|
31
|
-
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
32
|
-
jest.mock("../../../../lib/topology.generated", () => ({
|
|
33
|
-
__esModule: true,
|
|
34
|
-
TOPOLOGY: "spoke",
|
|
35
|
-
readSession: (headers: Headers) => mockReadSession(headers),
|
|
36
|
-
}));
|
|
37
|
-
|
|
38
|
-
import { GET } from "../[offering]/route";
|
|
39
|
-
|
|
40
|
-
type SuccessPath = (request: Request) => Promise<string | null>;
|
|
41
|
-
|
|
42
|
-
/** The `successPath` the route built its handlers with. */
|
|
43
|
-
async function successPathHook(): Promise<SuccessPath> {
|
|
44
|
-
await GET(new Request("https://acme.example.com/api/checkout/example"), {
|
|
45
|
-
params: Promise.resolve({ offering: "example" }),
|
|
46
|
-
});
|
|
47
|
-
expect(mockCreateSpokeCheckout).toHaveBeenCalledTimes(1);
|
|
48
|
-
const [, options] = mockCreateSpokeCheckout.mock.calls[0] as [
|
|
49
|
-
unknown,
|
|
50
|
-
{ successPath?: SuccessPath } | undefined,
|
|
51
|
-
];
|
|
52
|
-
expect(typeof options?.successPath).toBe("function");
|
|
53
|
-
return options!.successPath!;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
describe("the buy route's success path (#8757 AC-10)", () => {
|
|
57
|
-
let successPath: SuccessPath;
|
|
58
|
-
|
|
59
|
-
beforeAll(async () => {
|
|
60
|
-
successPath = await successPathHook();
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
beforeEach(() => {
|
|
64
|
-
mockReadSession.mockReset();
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it("a request with a session answers /account", async () => {
|
|
68
|
-
mockReadSession.mockResolvedValue({
|
|
69
|
-
user: { email: "cleo@x.io", emailVerified: true },
|
|
70
|
-
});
|
|
71
|
-
const request = new Request("https://acme.example.com/api/checkout/example", {
|
|
72
|
-
headers: { cookie: "session=abc" },
|
|
73
|
-
});
|
|
74
|
-
await expect(successPath(request)).resolves.toBe("/account");
|
|
75
|
-
expect(mockReadSession).toHaveBeenCalledTimes(1);
|
|
76
|
-
const [headers] = mockReadSession.mock.calls[0] as [Headers];
|
|
77
|
-
expect(headers.get("cookie")).toBe("session=abc");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("a request without one answers null", async () => {
|
|
81
|
-
mockReadSession.mockResolvedValue(null);
|
|
82
|
-
await expect(
|
|
83
|
-
successPath(new Request("https://acme.example.com/api/checkout/example")),
|
|
84
|
-
).resolves.toBeNull();
|
|
85
|
-
});
|
|
86
|
-
});
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment node
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The standard Stripe webhook route (#8748 D1, D11; spec
|
|
7
|
-
* docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2, AC-4).
|
|
8
|
-
*
|
|
9
|
-
* The checkout package is mocked: its verification, filters, retries and
|
|
10
|
-
* duplicate guard are proven by its own suites. These cases prove the route
|
|
11
|
-
* is wiring only — built once from `spokeCheckoutConfig`, serving the
|
|
12
|
-
* package's `webhook` handler on a spoke and a 404 anywhere else.
|
|
13
|
-
*/
|
|
14
|
-
import { readFileSync } from "node:fs";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
|
|
17
|
-
const mockWebhookPost = jest.fn();
|
|
18
|
-
const mockCreateSpokeCheckout = jest.fn((_config: unknown) => ({
|
|
19
|
-
webhook: { POST: mockWebhookPost },
|
|
20
|
-
}));
|
|
21
|
-
// `virtual`: CI runs this suite before the package's dist is built, so it
|
|
22
|
-
// may not resolve at all — the factory stands in either way.
|
|
23
|
-
jest.mock(
|
|
24
|
-
"@working-theory/spoke-checkout",
|
|
25
|
-
() => ({
|
|
26
|
-
__esModule: true,
|
|
27
|
-
createSpokeCheckout: (config: unknown) => mockCreateSpokeCheckout(config),
|
|
28
|
-
}),
|
|
29
|
-
{ virtual: true },
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
|
|
33
|
-
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
34
|
-
jest.mock("../../../../../lib/topology.generated", () => ({
|
|
35
|
-
__esModule: true,
|
|
36
|
-
get TOPOLOGY() {
|
|
37
|
-
return mockTopology.value;
|
|
38
|
-
},
|
|
39
|
-
}));
|
|
40
|
-
|
|
41
|
-
import { checkoutConfig } from "../../../../../lib/checkout-config";
|
|
42
|
-
import { spokeCheckoutConfig } from "../../../../../lib/checkout";
|
|
43
|
-
import { POST } from "../route";
|
|
44
|
-
|
|
45
|
-
const ROUTE_SOURCE = readFileSync(join(__dirname, "..", "route.ts"), "utf8");
|
|
46
|
-
|
|
47
|
-
function delivery(): Request {
|
|
48
|
-
return new Request("https://acme.example.com/api/webhooks/stripe", {
|
|
49
|
-
method: "POST",
|
|
50
|
-
body: "{}",
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
describe("the standard Stripe webhook route (#8748 AC-2)", () => {
|
|
55
|
-
beforeEach(() => {
|
|
56
|
-
mockWebhookPost.mockReset();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("answers 404 with no-store on a hub, and never builds the package's handlers (D11)", async () => {
|
|
60
|
-
mockTopology.value = "hub";
|
|
61
|
-
const res = await POST(delivery());
|
|
62
|
-
expect(res.status).toBe(404);
|
|
63
|
-
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
64
|
-
expect(mockCreateSpokeCheckout).not.toHaveBeenCalled();
|
|
65
|
-
expect(mockWebhookPost).not.toHaveBeenCalled();
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it("serves the package's webhook handler on a spoke, built once from spokeCheckoutConfig", async () => {
|
|
69
|
-
mockTopology.value = "spoke";
|
|
70
|
-
const answered = new Response(null, { status: 200 });
|
|
71
|
-
mockWebhookPost.mockResolvedValue(answered);
|
|
72
|
-
|
|
73
|
-
const first = delivery();
|
|
74
|
-
expect(await POST(first)).toBe(answered);
|
|
75
|
-
expect(mockWebhookPost).toHaveBeenCalledWith(first);
|
|
76
|
-
const second = delivery();
|
|
77
|
-
expect(await POST(second)).toBe(answered);
|
|
78
|
-
expect(mockWebhookPost).toHaveBeenLastCalledWith(second);
|
|
79
|
-
|
|
80
|
-
expect(mockCreateSpokeCheckout).toHaveBeenCalledTimes(1);
|
|
81
|
-
const [config] = mockCreateSpokeCheckout.mock.calls[0] as [
|
|
82
|
-
typeof spokeCheckoutConfig,
|
|
83
|
-
];
|
|
84
|
-
expect(config).toBe(spokeCheckoutConfig);
|
|
85
|
-
expect(config.offerings).toBe(checkoutConfig.offerings);
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
|
|
89
|
-
expect(ROUTE_SOURCE).not.toContain("STRIPE_");
|
|
90
|
-
expect(ROUTE_SOURCE).not.toContain("stripe-client");
|
|
91
|
-
});
|
|
92
|
-
});
|
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
// The Stripe webhook route — the standard spoke checkout (#8748), kept current
|
|
2
|
-
// by `create-ailk update`: do not edit it. Every guarantee lives in the
|
|
3
|
-
// spoke-checkout package and arrives by a package bump. This site's checkout
|
|
4
|
-
// data is lib/checkout-config.ts, the one file to edit. It answers 404 on any
|
|
5
|
-
// build that is not a spoke.
|
|
6
|
-
import { createSpokeCheckout } from "@working-theory/spoke-checkout";
|
|
7
|
-
|
|
8
|
-
import {
|
|
9
|
-
checkoutNotServed,
|
|
10
|
-
checkoutServed,
|
|
11
|
-
spokeCheckoutConfig,
|
|
12
|
-
} from "../../../../lib/checkout";
|
|
13
|
-
|
|
14
|
-
export const runtime = "nodejs";
|
|
15
|
-
export const dynamic = "force-dynamic";
|
|
16
|
-
|
|
17
|
-
type WebhookRoute = ReturnType<typeof createSpokeCheckout>["webhook"];
|
|
18
|
-
|
|
19
|
-
let webhook: WebhookRoute | undefined;
|
|
20
|
-
|
|
21
|
-
/** Built on the first served request and kept for every later one. */
|
|
22
|
-
function webhookRoute(): WebhookRoute {
|
|
23
|
-
webhook ??= createSpokeCheckout(spokeCheckoutConfig).webhook;
|
|
24
|
-
return webhook;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// platform-decisions-waiver: api-versioning — the endpoint URL registered in Stripe, not a versioned API contract (spec 8748 §7)
|
|
28
|
-
export async function POST(request: Request): Promise<Response> {
|
|
29
|
-
if (!checkoutServed()) return checkoutNotServed();
|
|
30
|
-
return webhookRoute().POST(request);
|
|
31
|
-
}
|