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
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* D17). Posts `unsubscribeAction(token)` on click only — never on render,
|
|
6
6
|
* so a mail scanner's GET prefetch of the page writes nothing.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* (
|
|
8
|
+
* The button label and the done message are the newsletter magnet's
|
|
9
|
+
* (#8847 D12), passed as STRINGS: a formatting FUNCTION handed across from the
|
|
10
|
+
* page (a Server Component) cannot be serialized into the Flight payload —
|
|
11
|
+
* the scaffold matrix's `GET /unsubscribe -> 500`
|
|
12
|
+
* (`unsubscribe-rsc-boundary.test.tsx` holds the regression). The token
|
|
13
|
+
* states (invalid, already, unavailable) read their own catalog lines
|
|
14
|
+
* through `useTranslations`.
|
|
14
15
|
*/
|
|
15
16
|
|
|
16
17
|
import { Button, InlineMessage } from "@working-theory/ui/primitives";
|
|
@@ -19,7 +20,21 @@ import { useState, useTransition, type ReactElement } from "react";
|
|
|
19
20
|
|
|
20
21
|
import { unsubscribeAction, type UnsubscribeResult } from "./unsubscribe.actions";
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
interface UnsubscribeButtonProps {
|
|
24
|
+
token: string | null;
|
|
25
|
+
/** The button label (the magnet's `unsubscribe.cta`). */
|
|
26
|
+
cta: string;
|
|
27
|
+
/** The done message (the magnet's `unsubscribe.done`). */
|
|
28
|
+
doneHeadline: string;
|
|
29
|
+
doneBody?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function UnsubscribeButton({
|
|
33
|
+
token,
|
|
34
|
+
cta,
|
|
35
|
+
doneHeadline,
|
|
36
|
+
doneBody,
|
|
37
|
+
}: UnsubscribeButtonProps): ReactElement {
|
|
23
38
|
const t = useTranslations("unsubscribe");
|
|
24
39
|
const [result, setResult] = useState<UnsubscribeResult | null>(null);
|
|
25
40
|
const [pending, startTransition] = useTransition();
|
|
@@ -35,7 +50,13 @@ export function UnsubscribeButton({ token }: { token: string | null }): ReactEle
|
|
|
35
50
|
if (result?.kind === "done") {
|
|
36
51
|
return (
|
|
37
52
|
<InlineMessage variant="success" role="status" data-testid="unsubscribe-done">
|
|
38
|
-
{
|
|
53
|
+
{/* Headline over body, as the /contact ending stacks them (#8847 D12). */}
|
|
54
|
+
<span className="flex flex-col">
|
|
55
|
+
<span className="font-semibold" data-testid="unsubscribe-done-headline">
|
|
56
|
+
{doneHeadline}
|
|
57
|
+
</span>
|
|
58
|
+
{doneBody !== undefined && <span className="mt-1">{doneBody}</span>}
|
|
59
|
+
</span>
|
|
39
60
|
</InlineMessage>
|
|
40
61
|
);
|
|
41
62
|
}
|
|
@@ -67,7 +88,7 @@ export function UnsubscribeButton({ token }: { token: string | null }): ReactEle
|
|
|
67
88
|
}}
|
|
68
89
|
data-testid="unsubscribe-button"
|
|
69
90
|
>
|
|
70
|
-
{
|
|
91
|
+
{cta}
|
|
71
92
|
</Button>
|
|
72
93
|
);
|
|
73
94
|
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// The Offerings buy route (#9051, spec
|
|
2
|
+
// docs/specs/9051-the-offerings-buy-route-api-spec.md). Every Stripe card on
|
|
3
|
+
// /pricing links `/api/offerings/checkout/<lookup_key>` (#8927); this route
|
|
4
|
+
// starts the hosted Checkout for that key and answers a 303 to it.
|
|
5
|
+
//
|
|
6
|
+
// Only a buyer's page load starts anything: a prefetch, a crawler, a
|
|
7
|
+
// cross-site load or an ungrammatical key is refused before any call. The
|
|
8
|
+
// route then confirms, from a shown-set read shared for 15 seconds, that the
|
|
9
|
+
// site is connected and shows the key — an unconnected site's POST would take
|
|
10
|
+
// the API's env path (#8931 A9) — and POSTs `/v1/checkout/sessions` with
|
|
11
|
+
// success and cancel URLs on the site's configured origin, never the
|
|
12
|
+
// request's. Every refusal is a 303 to `/pricing` with no body and one fixed
|
|
13
|
+
// log line; no response or log line carries the key.
|
|
14
|
+
import { checkout, offerings } from "@working-theory/api-client";
|
|
15
|
+
import { parseLookupKey } from "@working-theory/validation";
|
|
16
|
+
|
|
17
|
+
import { isShownLookupKey } from "../../../../../lib/offerings-pricing";
|
|
18
|
+
import { FALLBACK_BASE_URL } from "../../../../../lib/site-brand";
|
|
19
|
+
import { resolveCommerceClaim } from "../../../../../src/lib/commerce-claim";
|
|
20
|
+
import { resolveBaseUrl } from "../../../../../src/lib/routes";
|
|
21
|
+
|
|
22
|
+
export const runtime = "nodejs";
|
|
23
|
+
export const dynamic = "force-dynamic";
|
|
24
|
+
|
|
25
|
+
type RefusalReason =
|
|
26
|
+
| "unintended"
|
|
27
|
+
| "ungrammatical"
|
|
28
|
+
| "no_claim"
|
|
29
|
+
| "unconnected"
|
|
30
|
+
| "not_shown"
|
|
31
|
+
| "no_origin"
|
|
32
|
+
| "rate_limited"
|
|
33
|
+
| "unreachable"
|
|
34
|
+
| "bad_answer"
|
|
35
|
+
| `api_${number}`;
|
|
36
|
+
|
|
37
|
+
/** D6 — relative, so a misconfigured base URL can never send a buyer off-site. */
|
|
38
|
+
function refuse(reason: RefusalReason): Response {
|
|
39
|
+
console.warn(`[offerings-checkout] refused reason=${reason}`);
|
|
40
|
+
return new Response(null, {
|
|
41
|
+
status: 303,
|
|
42
|
+
headers: { Location: "/pricing", "Cache-Control": "no-store" },
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1"]);
|
|
47
|
+
const PLACEHOLDER_ORIGIN = new URL(FALLBACK_BASE_URL).origin;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* D2 step 1 — a top-level browser navigation, and not a prefetch, an RSC
|
|
51
|
+
* fetch or a cross-site load: the spoke buy route's refusal set plus the
|
|
52
|
+
* navigation requirement, so no crawler, unfurler, `<img>` or `fetch` opens a
|
|
53
|
+
* Stripe session.
|
|
54
|
+
*/
|
|
55
|
+
function isBuyerNavigation(request: Request): boolean {
|
|
56
|
+
const headers = request.headers;
|
|
57
|
+
if (request.method === "HEAD") return false;
|
|
58
|
+
if (headers.get("sec-fetch-mode") !== "navigate") return false;
|
|
59
|
+
if (headers.get("sec-fetch-dest") !== "document") return false;
|
|
60
|
+
if (headers.get("sec-fetch-site") === "cross-site") return false;
|
|
61
|
+
const secPurpose = (headers.get("sec-purpose") ?? "").toLowerCase();
|
|
62
|
+
const purpose = (headers.get("purpose") ?? "").toLowerCase();
|
|
63
|
+
if (secPurpose.includes("prefetch") || purpose.includes("prefetch"))
|
|
64
|
+
return false;
|
|
65
|
+
if ((headers.get("x-moz") ?? "").toLowerCase() === "prefetch") return false;
|
|
66
|
+
if (headers.get("next-router-prefetch") !== null) return false;
|
|
67
|
+
if (headers.get("rsc") !== null) return false;
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The API origin, in the order `getApiBase()` in
|
|
73
|
+
* packages/api-client/src/http.ts reads it.
|
|
74
|
+
*/
|
|
75
|
+
function configuredApiBase(): string | undefined {
|
|
76
|
+
return (
|
|
77
|
+
process.env.AILK_API_URL ||
|
|
78
|
+
process.env.NEXT_PUBLIC_AILK_API_URL ||
|
|
79
|
+
process.env.NEXT_PUBLIC_API_URL ||
|
|
80
|
+
undefined
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `work`'s answer, or `late` once `ms` pass first. */
|
|
85
|
+
function settleWithin<T>(work: Promise<T>, ms: number, late: T): Promise<T> {
|
|
86
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
87
|
+
const bound = new Promise<T>((resolve) => {
|
|
88
|
+
timer = setTimeout(() => resolve(late), ms);
|
|
89
|
+
});
|
|
90
|
+
return Promise.race([work, bound]).finally(() => clearTimeout(timer));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── D3 — the shared shown-set read ─────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
type ShownRead =
|
|
96
|
+
| { kind: "unconnected" }
|
|
97
|
+
| { kind: "shown"; shownSet: unknown }
|
|
98
|
+
| { kind: "other" };
|
|
99
|
+
|
|
100
|
+
const OTHER: ShownRead = { kind: "other" };
|
|
101
|
+
|
|
102
|
+
/** `/pricing`'s window: a product switched off stops being bought within it. */
|
|
103
|
+
const READ_TTL_MS = 15_000;
|
|
104
|
+
const READ_TIMEOUT_MS = 5_000;
|
|
105
|
+
|
|
106
|
+
const reads = new Map<string, { at: number; read: Promise<ShownRead> }>();
|
|
107
|
+
|
|
108
|
+
/** The shown set as the page reads it; the body is untrusted. */
|
|
109
|
+
async function readShown(): Promise<ShownRead> {
|
|
110
|
+
try {
|
|
111
|
+
const res = await offerings.shown();
|
|
112
|
+
if (!res.ok) return OTHER;
|
|
113
|
+
const body: unknown = res.body;
|
|
114
|
+
if (body === null || typeof body !== "object") return OTHER;
|
|
115
|
+
const { status, shownSet } = body as {
|
|
116
|
+
status?: unknown;
|
|
117
|
+
shownSet?: unknown;
|
|
118
|
+
};
|
|
119
|
+
if (status === "unconnected") return { kind: "unconnected" };
|
|
120
|
+
if (status === "shown") return { kind: "shown", shownSet };
|
|
121
|
+
return OTHER;
|
|
122
|
+
} catch {
|
|
123
|
+
return OTHER;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* One read per instance per READ_TTL_MS: `offerings.shown()` forwards no
|
|
129
|
+
* visitor header, so a read per click would count every buyer against this
|
|
130
|
+
* server's one per-IP limit at the API. Concurrent and failed reads are
|
|
131
|
+
* shared too, and the shared promise settles within READ_TIMEOUT_MS.
|
|
132
|
+
*/
|
|
133
|
+
function sharedShownRead(): Promise<ShownRead> {
|
|
134
|
+
const now = Date.now();
|
|
135
|
+
const hit = reads.get("shown-offerings");
|
|
136
|
+
if (hit && now - hit.at < READ_TTL_MS) return hit.read;
|
|
137
|
+
const read = settleWithin(readShown(), READ_TIMEOUT_MS, OTHER);
|
|
138
|
+
reads.set("shown-offerings", { at: now, read });
|
|
139
|
+
return read;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ─── D2 steps 6 and 7 — the site origin and the per-visitor budget ──────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The configured site origin: `https:`, or `http:` on loopback, and never
|
|
146
|
+
* the placeholder `resolveBaseUrl()` answers when no base URL is set.
|
|
147
|
+
*/
|
|
148
|
+
function siteOrigin(): string | null {
|
|
149
|
+
let url: URL;
|
|
150
|
+
try {
|
|
151
|
+
url = new URL(resolveBaseUrl());
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
const secure =
|
|
156
|
+
url.protocol === "https:" ||
|
|
157
|
+
(url.protocol === "http:" && LOOPBACK_HOSTS.has(url.hostname));
|
|
158
|
+
if (!secure || url.origin === PLACEHOLDER_ORIGIN) return null;
|
|
159
|
+
return url.origin;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const BUDGET_STARTS = 5;
|
|
163
|
+
const BUDGET_WINDOW_MS = 10 * 60_000;
|
|
164
|
+
const BUDGET_MAX_ADDRESSES = 10_000;
|
|
165
|
+
/** An address key is cut to this length, so the Map cannot grow by key size. */
|
|
166
|
+
const BUDGET_ADDRESS_LENGTH = 64;
|
|
167
|
+
|
|
168
|
+
const starts = new Map<string, { windowStart: number; count: number }>();
|
|
169
|
+
|
|
170
|
+
function visitorAddress(headers: Headers): string | null {
|
|
171
|
+
const address =
|
|
172
|
+
headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
|
|
173
|
+
headers.get("x-real-ip")?.trim();
|
|
174
|
+
return address ? address.slice(0, BUDGET_ADDRESS_LENGTH) : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Charges one start to `address`, or answers false when its window is spent.
|
|
179
|
+
* The oldest-inserted address is evicted at BUDGET_MAX_ADDRESSES.
|
|
180
|
+
*/
|
|
181
|
+
function spendStart(address: string, now: number): boolean {
|
|
182
|
+
const entry = starts.get(address);
|
|
183
|
+
if (entry && now - entry.windowStart < BUDGET_WINDOW_MS) {
|
|
184
|
+
if (entry.count >= BUDGET_STARTS) return false;
|
|
185
|
+
entry.count += 1;
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
188
|
+
if (entry) {
|
|
189
|
+
starts.delete(address);
|
|
190
|
+
} else if (starts.size >= BUDGET_MAX_ADDRESSES) {
|
|
191
|
+
const oldest = starts.keys().next().value;
|
|
192
|
+
if (oldest !== undefined) starts.delete(oldest);
|
|
193
|
+
}
|
|
194
|
+
starts.set(address, { windowStart: now, count: 1 });
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ─── D2 steps 8 and 9 — the start and its answer ────────────────────────────
|
|
199
|
+
|
|
200
|
+
const START_TIMEOUT_MS = 10_000;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* D5 — the visitor's forwarded address, so the API's per-IP bucket counts
|
|
204
|
+
* buyers, and the internal secret only from a site that is not a spoke and
|
|
205
|
+
* only to an `https:` or loopback API. Nothing else of the request is sent.
|
|
206
|
+
*/
|
|
207
|
+
function startHeaders(incoming: Headers): Record<string, string> {
|
|
208
|
+
const headers: Record<string, string> = {};
|
|
209
|
+
const visitor = incoming.get("x-forwarded-for") || incoming.get("x-real-ip");
|
|
210
|
+
if (visitor) headers["x-forwarded-for"] = visitor;
|
|
211
|
+
|
|
212
|
+
const secret = process.env.OSS_API_INTERNAL_SECRET;
|
|
213
|
+
if (secret && !process.env.AILK_SPOKE_KEY?.trim() && isSafeApiTransport()) {
|
|
214
|
+
headers["x-internal-auth"] = secret;
|
|
215
|
+
}
|
|
216
|
+
return headers;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function isSafeApiTransport(): boolean {
|
|
220
|
+
try {
|
|
221
|
+
const url = new URL(configuredApiBase() ?? "http://localhost:3001");
|
|
222
|
+
return url.protocol === "https:" || LOOPBACK_HOSTS.has(url.hostname);
|
|
223
|
+
} catch {
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* The answer's Checkout URL: `https:`, no credentials, and exactly its own
|
|
230
|
+
* canonical form, so `Location` never carries a string the parser would
|
|
231
|
+
* rewrite (a CR, LF or tab, a non-ASCII character, a missing slash).
|
|
232
|
+
*/
|
|
233
|
+
function checkoutUrl(body: unknown): string | null {
|
|
234
|
+
if (body === null || typeof body !== "object") return null;
|
|
235
|
+
const { url } = body as { url?: unknown };
|
|
236
|
+
if (typeof url !== "string") return null;
|
|
237
|
+
let parsed: URL;
|
|
238
|
+
try {
|
|
239
|
+
parsed = new URL(url);
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
if (parsed.protocol !== "https:") return null;
|
|
244
|
+
if (parsed.username || parsed.password) return null;
|
|
245
|
+
return parsed.href === url ? url : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ─── The ladder (D2) ────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
interface BuyContext {
|
|
251
|
+
params: Promise<{ lookupKey: string }>;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function startCheckout(
|
|
255
|
+
request: Request,
|
|
256
|
+
ctx: BuyContext,
|
|
257
|
+
): Promise<Response> {
|
|
258
|
+
const { lookupKey } = await ctx.params;
|
|
259
|
+
|
|
260
|
+
if (!isBuyerNavigation(request)) return refuse("unintended");
|
|
261
|
+
if (!parseLookupKey(lookupKey)) return refuse("ungrammatical");
|
|
262
|
+
if (!(await resolveCommerceClaim())) return refuse("no_claim");
|
|
263
|
+
if (process.env.NODE_ENV === "production" && !configuredApiBase()) {
|
|
264
|
+
return refuse("unconnected");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const shown = await sharedShownRead();
|
|
268
|
+
if (shown.kind === "unconnected") return refuse("unconnected");
|
|
269
|
+
if (shown.kind !== "shown" || !isShownLookupKey(shown.shownSet, lookupKey)) {
|
|
270
|
+
return refuse("not_shown");
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const origin = siteOrigin();
|
|
274
|
+
if (!origin) return refuse("no_origin");
|
|
275
|
+
|
|
276
|
+
const address = visitorAddress(request.headers);
|
|
277
|
+
if (address && !spendStart(address, Date.now())) {
|
|
278
|
+
return refuse("rate_limited");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const started = await settleWithin(
|
|
282
|
+
checkout
|
|
283
|
+
.createSession(
|
|
284
|
+
{
|
|
285
|
+
productCode: lookupKey,
|
|
286
|
+
successUrl: `${origin}/purchase/success?session_id={CHECKOUT_SESSION_ID}`,
|
|
287
|
+
cancelUrl: `${origin}/pricing`,
|
|
288
|
+
},
|
|
289
|
+
{ headers: startHeaders(request.headers) },
|
|
290
|
+
)
|
|
291
|
+
.catch(() => null),
|
|
292
|
+
START_TIMEOUT_MS,
|
|
293
|
+
null,
|
|
294
|
+
);
|
|
295
|
+
if (!started) return refuse("unreachable");
|
|
296
|
+
if (!started.ok) return refuse(`api_${started.status}`);
|
|
297
|
+
|
|
298
|
+
const url = checkoutUrl(started.body);
|
|
299
|
+
if (!url) return refuse("bad_answer");
|
|
300
|
+
return new Response(null, {
|
|
301
|
+
status: 303,
|
|
302
|
+
headers: { Location: url, "Cache-Control": "no-store" },
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// platform-decisions-waiver: api-versioning — a browser buy link that redirects to Stripe Checkout, not a versioned API contract (spec 9051 D1)
|
|
307
|
+
export async function GET(
|
|
308
|
+
request: Request,
|
|
309
|
+
ctx: BuyContext,
|
|
310
|
+
): Promise<Response> {
|
|
311
|
+
try {
|
|
312
|
+
return await startCheckout(request, ctx);
|
|
313
|
+
} catch {
|
|
314
|
+
return refuse("unreachable");
|
|
315
|
+
}
|
|
316
|
+
}
|