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,32 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* subscribe_newsletter — add an email to the newsletter list
|
|
2
|
+
* subscribe_newsletter — add an email to the newsletter list: the body
|
|
3
|
+
* `POST /v1/leads/subscribe` takes.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* The tool adopted the API, arguments and results (#7172 — the operator's
|
|
6
|
+
* decision of 2026-10-19). Its input IS the route's `leadSubscribeBodySchema`
|
|
7
|
+
* (`{ email, source }`; the catalog registers the same shape, so the SDK has
|
|
8
|
+
* already parsed the arguments against it — the `safeParse` here keeps the
|
|
9
|
+
* stdio path and the `invalid_input` branch honest), the parsed body is
|
|
10
|
+
* posted as the agent sent it — no `siteId`, no stamped `source`, and no
|
|
11
|
+
* `firstName`, which never reached the wire — and the route's 201 body
|
|
12
|
+
* `{ id, persisted, email }` comes back unchanged. No spoke marker: a
|
|
13
|
+
* Subscriber row has no payload to carry it. No business logic —
|
|
14
|
+
* orchestration lives in apps/api.
|
|
7
15
|
*
|
|
8
|
-
* Input: { email, firstName? }
|
|
9
|
-
* Output: { ok: true, id, persisted } | { ok: false, error: { kind, ... } }
|
|
10
16
|
* Session/scope: none — anonymous write, no auth required.
|
|
11
17
|
*/
|
|
12
18
|
|
|
13
19
|
import { apiClient } from "@working-theory/api-client";
|
|
14
20
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
type
|
|
18
|
-
type SubscribeNewsletterOutput,
|
|
21
|
+
leadSubscribeBodySchema,
|
|
22
|
+
type LeadSubscribeBody,
|
|
23
|
+
type LeadWriteOutput,
|
|
19
24
|
} from "@working-theory/validation";
|
|
20
25
|
|
|
21
26
|
import { visitorForwardHeadersForThisCall } from "../request-context.js";
|
|
22
27
|
|
|
23
|
-
export {
|
|
24
|
-
export type {
|
|
28
|
+
export { leadSubscribeBodySchema };
|
|
29
|
+
export type { LeadSubscribeBody };
|
|
25
30
|
|
|
26
31
|
export async function ailkSubscribeNewsletter(
|
|
27
|
-
input:
|
|
28
|
-
): Promise<
|
|
29
|
-
const parsed =
|
|
32
|
+
input: LeadSubscribeBody,
|
|
33
|
+
): Promise<LeadWriteOutput> {
|
|
34
|
+
const parsed = leadSubscribeBodySchema.safeParse(input);
|
|
30
35
|
if (!parsed.success) {
|
|
31
36
|
return {
|
|
32
37
|
ok: false,
|
|
@@ -34,24 +39,16 @@ export async function ailkSubscribeNewsletter(
|
|
|
34
39
|
};
|
|
35
40
|
}
|
|
36
41
|
|
|
37
|
-
const { email } = parsed.data;
|
|
38
|
-
// firstName is not part of the canonical subscribeSchema (no payload field);
|
|
39
|
-
// it is accepted by the MCP input but dropped here for now. The #7397 spoke
|
|
40
|
-
// marker (LEAD_SITE_MARKER_KEY) is absent for the same reason: a Subscriber
|
|
41
|
-
// row has no payload column, so subscribe has nowhere to carry it.
|
|
42
|
-
const siteId = process.env.AILK_SITE_ID ?? "default";
|
|
43
|
-
const body = { siteId, email, source: "mcp" };
|
|
44
|
-
|
|
45
42
|
// #8073 — forward the address the transport trusted (never an identity
|
|
46
43
|
// argument on this tool's own schema) so the hub counts the write in that
|
|
47
44
|
// visitor's own rate-limit bucket rather than this MCP host's.
|
|
48
45
|
const headers = visitorForwardHeadersForThisCall();
|
|
49
46
|
const res = headers
|
|
50
|
-
? await apiClient.leads.subscribe(
|
|
51
|
-
: await apiClient.leads.subscribe(
|
|
47
|
+
? await apiClient.leads.subscribe(parsed.data, { headers })
|
|
48
|
+
: await apiClient.leads.subscribe(parsed.data);
|
|
52
49
|
if (!res.ok) {
|
|
53
50
|
return { ok: false, error: { kind: "api_error", status: res.status } };
|
|
54
51
|
}
|
|
55
52
|
|
|
56
|
-
return
|
|
53
|
+
return res.body;
|
|
57
54
|
}
|
|
@@ -83,6 +83,21 @@ const WORKSPACE_TOOL_ENTRIES: Record<
|
|
|
83
83
|
specifier: "./tools/inbox_submissions.js",
|
|
84
84
|
exportName: "ailkMarkInboxSubmissionDone",
|
|
85
85
|
},
|
|
86
|
+
list_listing_applications: {
|
|
87
|
+
specifier: "./tools/inbox_applications.js",
|
|
88
|
+
exportName: "ailkListListingApplications",
|
|
89
|
+
},
|
|
90
|
+
get_listing_application: {
|
|
91
|
+
specifier: "./tools/inbox_applications.js",
|
|
92
|
+
exportName: "ailkGetListingApplication",
|
|
93
|
+
},
|
|
94
|
+
decide_listing_application: {
|
|
95
|
+
specifier: "./tools/inbox_applications.js",
|
|
96
|
+
exportName: "ailkDecideListingApplication",
|
|
97
|
+
},
|
|
98
|
+
// #9137 — the pre-rename names, deprecated aliases of the three above for
|
|
99
|
+
// one release: each export is the same function, so the same arguments and
|
|
100
|
+
// result.
|
|
86
101
|
list_inbox_applications: {
|
|
87
102
|
specifier: "./tools/inbox_applications.js",
|
|
88
103
|
exportName: "ailkListInboxApplications",
|
|
@@ -261,37 +261,16 @@ CONTENT_ADAPTER=mdx
|
|
|
261
261
|
# BETTER_STACK_HEARTBEAT_URL=
|
|
262
262
|
|
|
263
263
|
# =============================================================================
|
|
264
|
-
# Scheduling — the scheduling module's booking
|
|
264
|
+
# Scheduling — the scheduling module's booking pages (#4633, #8843)
|
|
265
265
|
# =============================================================================
|
|
266
|
-
#
|
|
267
|
-
#
|
|
268
|
-
#
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
#
|
|
273
|
-
#
|
|
274
|
-
# NOT a credential, and deliberately NOT a NEXT_PUBLIC_ variable — the API key
|
|
275
|
-
# it pairs with (CAL_API_KEY) lives in apps/api and this app never reads it, so
|
|
276
|
-
# keeping the whole scheduling config surface server-read is what makes that
|
|
277
|
-
# boundary checkable rather than merely intended.
|
|
278
|
-
# SCHEDULING_CAL_LINK=owner/event-slug
|
|
279
|
-
#
|
|
280
|
-
# The booker's /v1/schedule/* calls go through this site's same-origin relay
|
|
281
|
-
# (see the relay block above, #7644), so the baseline CSP's
|
|
282
|
-
# `connect-src 'self'` already covers them — no CSP extension is needed for
|
|
283
|
-
# them, whatever NEXT_PUBLIC_AILK_API_URL is set to.
|
|
266
|
+
# No variable here. /meet, /private and /meet/<slug> read every meeting fact
|
|
267
|
+
# — which meetings are Live, public or private, their titles, lengths and
|
|
268
|
+
# Cal.com links — from apps/api's GET /v1/schedule/meetings, which resolves
|
|
269
|
+
# the workspace from this site (the site alias keyless, a spoke by its key)
|
|
270
|
+
# and composes each Cal.com link from that workspace's own account. The
|
|
271
|
+
# one-meeting "<owner>/<event-slug>" variable this block used to set is
|
|
272
|
+
# retired, and nothing reads it.
|
|
284
273
|
#
|
|
285
274
|
# On a SPOKE (create-ailk --topology spoke, #7872) this site has no apps/api:
|
|
286
|
-
#
|
|
287
|
-
#
|
|
288
|
-
# - the FIRST segment (the owner) must name the provider account the hub
|
|
289
|
-
# uses for this spoke's workspace (its scheduling connection). It composes
|
|
290
|
-
# each booker's calLink; it is not an access control — the hub checks a
|
|
291
|
-
# spoke booking's slug, never its owner.
|
|
292
|
-
# - the SECOND segment is ignored: a workspace with no stored meeting-type
|
|
293
|
-
# list shows no meetings, because the hub books only a slug it stores.
|
|
294
|
-
# - the booker's two calls count against the relay's per-visitor budget,
|
|
295
|
-
# which holds per visitor only behind an edge that overwrites
|
|
296
|
-
# x-forwarded-for and x-real-ip (Vercel does); elsewhere every visitor
|
|
297
|
-
# shares one budget.
|
|
275
|
+
# the pages read the hub's meetings for this spoke's workspace at AILK_API_URL
|
|
276
|
+
# with AILK_SPOKE_KEY, so a workspace with no stored meeting list shows none.
|
|
@@ -143,6 +143,21 @@ the site's contact email, never a form that would report a message as sent
|
|
|
143
143
|
when it reached nobody. A failed status read counts as accepting, so the
|
|
144
144
|
form renders and its submit reports honestly.
|
|
145
145
|
|
|
146
|
+
**Every visible fact on the form comes from the magnet (#8847).** The page
|
|
147
|
+
reads the magnet through `lib/in-page-magnet.ts`, the in-page form shape:
|
|
148
|
+
its field step's `copy.title` is the page's H1 and `<title>` and its
|
|
149
|
+
`copy.body` the line under it (`framedSources` writes both into the first
|
|
150
|
+
`contact-form` section, and into the frontmatter title when that section
|
|
151
|
+
leads), and its `ending` is the success message (`{name}` filled from the
|
|
152
|
+
posted name). The section's own `submitLabel` and `successMessage` apply
|
|
153
|
+
only to its own fields. When the hub cannot be read the seeded
|
|
154
|
+
`contactFormFlowConfig` renders, so a fresh deploy looks the same. Under
|
|
155
|
+
the form one muted line reads "Prefer email?" (`contact.preferEmail`) and
|
|
156
|
+
the `_site.mdx` contact email, absent when the site has none. The default
|
|
157
|
+
Contact form's statement is the two-link "I accept the Terms and Privacy
|
|
158
|
+
Policy." (`CONTACT_FORM_TERMS_STATEMENT`); sign-up keeps the three-link
|
|
159
|
+
`ACCOUNT_TERMS_STATEMENT`.
|
|
160
|
+
|
|
146
161
|
The form's fields come from the workspace's **Contact form magnet** (#8661):
|
|
147
162
|
every workspace is seeded one, live at slug `contact`, and the page is rendered
|
|
148
163
|
on every request (`force-dynamic`) and reads it through the public flow read,
|
|
@@ -194,7 +209,7 @@ redirect, never into a code, cookie or continuation ref (a repeated
|
|
|
194
209
|
`login_hint` is `400 invalid_request`). `email` and `login_hint` are in the
|
|
195
210
|
telemetry scrub lists (`lib/telemetry-scrub.ts`, `packages/analytics`).
|
|
196
211
|
|
|
197
|
-
**The same link on the magnet walk and /careers (#8771).** The
|
|
212
|
+
**The same link on the magnet walk and /careers (#8771).** The careers apply page's action (`/careers/<slug>/apply` on a site without the walk, #8849) reads the API's `next` the same way (`apply.actions.ts`): `invite` returns the link and `contact.trackInAccount`, anything else — or a throw after the write — exactly `{ kind: "success" }`, so an error never invites a duplicate application. The walk (`/flows/<slug>`, `FlowStepperClient`) tracks the first `mapsTo: "email"` answer it submitted; on a completing response with `next: { completion: "invite" }` it asks the server action `accountInviteLinkAction` (the builder reads the generated topology, so it never runs in the client) and shows the link inside `FlowStepper`'s finished card (its `completedAction` slot), or — for a flow with a `thankYou` block — writes one `sessionStorage` item, `ailk:account-invite`, that `/thanks/<slug>`'s `AccountInviteLink` reads once and removes (the page passes it to `renderPage` as `trailing`, so it sits inside `<main>`). Each link sits in a `data-sentry-block` element (Session Replay records `href`s); no walk URL ever carries the email.
|
|
198
213
|
|
|
199
214
|
A `contact-form` section in the `column` variant (the minimalism design's
|
|
200
215
|
Contact page) renders one centred heading, one line and the form, and the
|
|
@@ -214,51 +229,296 @@ and on a declined terms turn, Skip or a failed submit swaps to
|
|
|
214
229
|
embedding for it is behind the hero CTA's lightbox gate
|
|
215
230
|
(`packages/ui/src/sections/FormLightbox.tsx`, #4577), not a whole-page mount.
|
|
216
231
|
|
|
232
|
+
## Newsletter, unsubscribe and the footer signup
|
|
233
|
+
|
|
234
|
+
The workspace's **Newsletter magnet** (#8847) — seeded live at slug
|
|
235
|
+
`newsletter` in every workspace — is the only source of `/newsletter`,
|
|
236
|
+
`/unsubscribe` and the footer signup; none of their copy is in page code,
|
|
237
|
+
content or the message catalog. All three read it through
|
|
238
|
+
`lib/in-page-magnet.ts`'s `readServedMagnet("newsletter")`, shared for 15
|
|
239
|
+
seconds per instance like /contact's read.
|
|
240
|
+
|
|
241
|
+
- **`/newsletter`** renders on every request. The magnet's step title is the
|
|
242
|
+
H1 and `<title>`, its body the line under it, and the standard
|
|
243
|
+
`NewsletterFormInline` (visible Email label, the consent row above the
|
|
244
|
+
field, the consent field's help line, Subscribe, and the magnet's ending in
|
|
245
|
+
place of the form) goes into the page's first `contact-form` section. With
|
|
246
|
+
the hub unreadable the seeded `newsletterFlowConfig` renders; with capture
|
|
247
|
+
off (`GET /v1/leads/capture`'s `newsletter`) the slot is `ContactFallback`
|
|
248
|
+
with the `_site.mdx` email. A page with no `contact-form` section keeps its
|
|
249
|
+
pre-#8847 composition.
|
|
250
|
+
- **The signup.** `subscribeNewsletterAction` refuses a post without
|
|
251
|
+
`consent=true`, then re-reads the magnet and submits its step through
|
|
252
|
+
`flows.submitStep` — the step key, the email field and the consent key
|
|
253
|
+
(`terms.newsletter`) all from the server's read, never the post — so the
|
|
254
|
+
hub writes the lead, the `newsletter` consent row and the welcome email.
|
|
255
|
+
Without a flow route (a Free build) it posts `leads.subscribe`, as before.
|
|
256
|
+
It lives in `lib/newsletter.actions.ts`, not under `app/[locale]/newsletter/`,
|
|
257
|
+
because the footer imports it and a scaffold with the `newsletter` surface
|
|
258
|
+
off drops that route directory.
|
|
259
|
+
- **`/unsubscribe`** takes its title, line, button and done message from the
|
|
260
|
+
magnet's `unsubscribe` block (strings only across the island edge); the
|
|
261
|
+
already, invalid and unavailable lines stay in the `unsubscribe` catalog.
|
|
262
|
+
- **The footer** fills `Footer`'s `subscribe` slot with the compact form
|
|
263
|
+
(stacked, `sr-only` label) only when the magnet is served and capture is
|
|
264
|
+
on; the built-in config never fills it.
|
|
265
|
+
|
|
266
|
+
## Careers
|
|
267
|
+
|
|
268
|
+
Three linked pages, each built from the `content/<locale>/JobPosting/*.mdx`
|
|
269
|
+
records (#8849):
|
|
270
|
+
|
|
271
|
+
- **`/careers`** renders in sections mode, the /alternatives pattern: one
|
|
272
|
+
generated `items` section first, a card per posting (newest `publishedAt`
|
|
273
|
+
first), then the seed's declared sections, so the card grid and the
|
|
274
|
+
`CollectionPage`'s `ItemList` (`ListItem`s with `position` and `url`) agree.
|
|
275
|
+
The section's `filter` gives a search box and employment-type and location
|
|
276
|
+
selects; the facets read the postings' first-class `employmentType` and
|
|
277
|
+
`location` frontmatter, never `schema.mainEntity`. Every posting stays in the
|
|
278
|
+
server HTML. Every visible string is a `careers` message.
|
|
279
|
+
- **`/careers/<slug>`** keeps its chrome and ends with an Apply button
|
|
280
|
+
(`job-apply-cta`, `Button` size `big`) to `/careers/<slug>/apply`; its
|
|
281
|
+
`ApplyAction` targets the same URL. It makes no hub read.
|
|
282
|
+
- **`/careers/<slug>/apply`** is the `signal-collectors` walk where the module
|
|
283
|
+
is present (§ Magnet pages), else the Free apply page,
|
|
284
|
+
`careers/[slug]/[action]/page.tsx`: the posting's title over the one-step
|
|
285
|
+
`LeadCaptureForm` bound to `apply.actions.ts` with the slug as `job`,
|
|
286
|
+
`noindex, follow` and full-screen. Next.js serves the static `apply`
|
|
287
|
+
segment first, the `[slug]/[detail]` idiom; every other action 404s. Lead
|
|
288
|
+
capture is never-gate, so every tier takes an application. Both apply pages
|
|
289
|
+
read the posting's title through `lib/posting-title.ts`.
|
|
290
|
+
|
|
291
|
+
## Meeting pages
|
|
292
|
+
|
|
293
|
+
The scheduling module's booking pages (#8843) read every meeting fact from
|
|
294
|
+
the workspace — no content file, env var or deploy sits between a Meetings or
|
|
295
|
+
Cal.com edit and the page.
|
|
296
|
+
|
|
297
|
+
- **One builder.** `lib/meetings.tsx` reads `GET /v1/schedule/meetings`
|
|
298
|
+
through `schedule.meetings()`, re-parses the body with `MeetingListSchema`
|
|
299
|
+
and shares the result per server instance for 15 seconds (concurrent and
|
|
300
|
+
failed reads too, the contact page's #8731 shape). Any failure is
|
|
301
|
+
`{ ok: false }`. It builds the index cards (`meetingCardsSection`: `items`
|
|
302
|
+
tiles, two columns, `gapAbove: "tight"`, one card per bookable meeting with
|
|
303
|
+
the hub's title and description and a "Book <n> min" button) and the
|
|
304
|
+
meeting page's booker (`meetingPageSections`: `scheduler-embed`, provider
|
|
305
|
+
`cal`, month view). Every visible string is a `schedule` message.
|
|
306
|
+
- **`/meet` and `/private`** both return `MeetingIndex`
|
|
307
|
+
(`app/[locale]/meet/meeting-index.tsx` — beside the route, because it
|
|
308
|
+
renders the paid `BookingProfileHeader`, which `architecture.yaml` admits
|
|
309
|
+
only there): the back link alone (`Nav` `bar="bare"` `bareMark={false}`),
|
|
310
|
+
the header's avatar and one H1, then the cards of that visibility. One
|
|
311
|
+
meeting redirects to `/meet/<slug>`; none, or a failed read, shows the
|
|
312
|
+
site's contact email — never a 404 or a 500. `/private` is
|
|
313
|
+
`noindex, nofollow`, has no seed and no nav entry: private means unlisted,
|
|
314
|
+
not access-controlled.
|
|
315
|
+
- **`/meet/<slug>`** is one centred H1 ("Schedule a <title> meeting"),
|
|
316
|
+
Cal.com's inline embed from the hub's Cal.com link, and "← All meeting
|
|
317
|
+
types". A Live meeting that cannot be booked, or a failed read for a valid
|
|
318
|
+
slug, shows the contact email; an unknown slug or a dotted one is a 404. A
|
|
319
|
+
`BookCall/<slug>.mdx` seed gives only the URL, the JSON-LD inputs and the
|
|
320
|
+
fallback title — its sections are never rendered here.
|
|
321
|
+
- **Chrome.** `lib/modules.generated.ts`'s `SCHEDULING_PAGES` (written by
|
|
322
|
+
create-ailk from `apps/web/package.json`, never copied by `update`) and
|
|
323
|
+
`lib/auth-routes.ts`'s `isBookingPath` make the locale layout drop the
|
|
324
|
+
marketing shell on `/meet`, `/meet/<slug>` and `/private`; `/meet/confirmed`
|
|
325
|
+
and a site without the module keep it. The consent banner stays.
|
|
326
|
+
- **The CSP (D12).** `security-headers.config.ts` exports `SCHEDULING_CSP`
|
|
327
|
+
(Cal.com's app origin in `script-src` and `connect-src`; `frame-src`
|
|
328
|
+
`'self'` and the same origin) and a frozen `SCHEDULING_SECURITY_HEADERS`.
|
|
329
|
+
`middleware.ts` applies it instead of `DEFAULT_SECURITY_HEADERS` only when
|
|
330
|
+
`SCHEDULING_PAGES && isBookingPath(pathname, enabledLocales)`; sign-in,
|
|
331
|
+
account, checkout, `/meet/confirmed` and every site without the module keep
|
|
332
|
+
the default policy. `architecture.yaml`'s `cal\.com` rule excludes the
|
|
333
|
+
config, the middleware and their two tests for this reason.
|
|
334
|
+
|
|
335
|
+
## Magnet pages
|
|
336
|
+
|
|
337
|
+
Every multi-step magnet is a page pair (#8840), rendered by one module,
|
|
338
|
+
`app/[locale]/flows/[slug]/magnet-page.tsx`:
|
|
339
|
+
|
|
340
|
+
- **`/<magnet>`** — the indexable landing, inside the site's Nav and Footer: a
|
|
341
|
+
centred hero of the flow record's `landing` copy (title, line, muted meta
|
|
342
|
+
line, button to `/<magnet>/start`; absent keys fall back to step 1's title
|
|
343
|
+
and the `flow` messages) and the `Assessment` page type's `WebApplication`
|
|
344
|
+
JSON-LD.
|
|
345
|
+
- **`/<magnet>/start`** — the walk, `noindex, follow`, full-screen with the
|
|
346
|
+
back arrow alone (`NavBackLink`) above step 1. `lib/magnet-routes.ts`'s
|
|
347
|
+
`isMagnetWalkPath` is what the locale layout drops the Nav and Footer on:
|
|
348
|
+
any path ending in `start`, plus `/careers/apply`,
|
|
349
|
+
`/careers/<posting>/apply` and `/trial/request`. The cookie consent banner
|
|
350
|
+
stays.
|
|
351
|
+
- **The ending.** A flow with an `ending` ends on it, never on `thankYou`:
|
|
352
|
+
`FlowStepperClient` picks the outcome from the walk's answers
|
|
353
|
+
(`selectFlowEndingOutcome`) — a screen in the finished card with `{name}`
|
|
354
|
+
filled, or a redirect to a site path — and re-checks every destination
|
|
355
|
+
against `flowSitePathSchema` before it renders or navigates.
|
|
356
|
+
- **The hub is down.** Both pages read the flow through one `flows.get`,
|
|
357
|
+
shared per instance for 15 seconds per slug; a refused connection or a 5xx
|
|
358
|
+
shows `ContactFallback` with the site's contact email (both pages
|
|
359
|
+
`noindex`), and a 404 is `notFound()`. `/careers/apply` exists apart from
|
|
360
|
+
its flow, so it passes `renderMagnetStart` a `title`: a 404, a refused
|
|
361
|
+
connection or a 5xx all render the contact email under that `<h1>`
|
|
362
|
+
(#9219).
|
|
363
|
+
- **The pairs.** `/flows/<slug>` is the canonical pair. `/careers/apply` and
|
|
364
|
+
`/careers/<posting>/apply` walk the Job application magnet (the second
|
|
365
|
+
titles step 1 with the posting and sends its slug as the hidden `job`).
|
|
366
|
+
`/trial` and `/trial/request` are the trial magnet's pair (#9287): the
|
|
367
|
+
landing passes `renderMagnetLanding` a `startPath`, so its one button
|
|
368
|
+
(the magnet's `landing.cta`, "Request a free trial") goes to
|
|
369
|
+
`/trial/request` rather than `/trial/start`, and the walk passes a
|
|
370
|
+
`title`, so an absent flow renders the contact email under its own `<h1>`,
|
|
371
|
+
as `/careers/apply` does. Both are `signal-collectors` slice files.
|
|
372
|
+
Any other pair is written by `create-ailk add-route magnet --path <path>
|
|
373
|
+
--flow <slug>`: two short pages carrying the `Standard magnet page
|
|
374
|
+
(#8840)` marker, which `lib/__tests__/structured-data.test.tsx` exempts
|
|
375
|
+
from its `renderPage` check. A site adds a magnet by binding a pair to a
|
|
376
|
+
slug, never by copying stepper code.
|
|
377
|
+
- **The recommender's landing (#9071).** A recommending flow with an offer
|
|
378
|
+
carrying `lookupKey` (an Offerings recommender, such as the standard
|
|
379
|
+
`recommender` magnet the Admin's Offerings page opens) never renders its
|
|
380
|
+
results page: the results permalink
|
|
381
|
+
(`flows/[slug]/results/[payload]/page.tsx`) redirects a completed walk to
|
|
382
|
+
`/<locale>/pricing?plan=<plan>` while the first fit's lookup key is in the
|
|
383
|
+
current shown set, else to `/<locale>/pricing` with no badge (no fit, a key
|
|
384
|
+
no longer shown, any failed read, or no answer within 5 seconds). The
|
|
385
|
+
shown-set read is `offerings.shown()`, `/pricing`'s own, shared per
|
|
386
|
+
instance for 15 seconds; `?plan=` carries the key's `{plan}` part, the only
|
|
387
|
+
value `/pricing` reads. A flow with no such offer, or whose first fit
|
|
388
|
+
carries none, renders the results page as before, with no read.
|
|
389
|
+
- **The deliverable pages (#8848).** A flow whose served config carries a
|
|
390
|
+
`deliverable` block (a `file`, `video`, `page` or `external` payload, gated
|
|
391
|
+
or not) has one route, `/reports/<slug>`, rendered by
|
|
392
|
+
`app/[locale]/reports/deliverable-page.tsx`; both halves of every other pair
|
|
393
|
+
redirect a deliverable flow there. Gated: the page is a centred hero of the
|
|
394
|
+
block's copy with the back arrow to `/reports` and a button to the walk at
|
|
395
|
+
`/reports/<slug>/start`, and the walk's finished card shows the deliverable
|
|
396
|
+
after the ending screen — the reader link `/reports/<slug>?g=<grant>` (in a
|
|
397
|
+
`data-sentry-block` element), an inline player, a site link or a link out;
|
|
398
|
+
a redirect ending never runs when the response carries one, and a file walk
|
|
399
|
+
that minted no grant says the link is in the email. Ungated: the reader over
|
|
400
|
+
the site's own `/reports/<slug>/file` route handler (it reads the hub's
|
|
401
|
+
`GET /v1/deliverables/open/:slug` with the spoke key and the visitor
|
|
402
|
+
forward, 30 reads per visitor per 10 minutes — skipped when no client
|
|
403
|
+
address is readable — the five file headers passed through), a player, a redirect to the site page, or a button out. Every
|
|
404
|
+
page under `/reports/<slug>` is chrome-free (`isMagnetWalkPath`); `/reports`
|
|
405
|
+
keeps its chrome. The reader's Share is built from the page URL, never the
|
|
406
|
+
bytes or `?g=`. A hub read that fails falls back to the slug's `ReportPage`
|
|
407
|
+
content. With none, #8848 D6 holds for a configured hub: a 5xx or a refused
|
|
408
|
+
connection shows the contact email (`noindex`). A site with no hub to ask —
|
|
409
|
+
the read answers 503 `flows_not_configured`, or no API origin
|
|
410
|
+
(`AILK_API_URL`, `NEXT_PUBLIC_AILK_API_URL`, `NEXT_PUBLIC_API_URL`) is set —
|
|
411
|
+
reads `not-configured` (set once in `readMagnet`), and the slug 404s, as
|
|
412
|
+
does its `/start` (#9219). `/downloads` and `/downloads/<slug>` are
|
|
413
|
+
permanent redirects into `/reports` (signal-collectors files).
|
|
414
|
+
|
|
415
|
+
## Pricing page
|
|
416
|
+
|
|
417
|
+
The pricing page (`apps/web/app/[locale]/pricing/page.tsx`) renders the
|
|
418
|
+
workspace's Offerings shown set from Stripe (#8927). Before rendering it
|
|
419
|
+
reads `GET /v1/content/shown-offerings` through `offerings.shown()` from
|
|
420
|
+
`@working-theory/api-client`, renders on every request (`force-dynamic`),
|
|
421
|
+
and shares the read across requests for 15 seconds per server instance, the
|
|
422
|
+
contact page's window and reason: a product switched off disappears within
|
|
423
|
+
15 seconds, with no deploy. The API resolves the workspace (a spoke by its
|
|
424
|
+
key), so the page names none. It renders one of three pages:
|
|
425
|
+
|
|
426
|
+
- **Unconnected** — the route answers `unconnected`, or the deployment runs
|
|
427
|
+
in production with none of the API origin variables `getApiBase()` reads:
|
|
428
|
+
today's MDX page through `renderPage`, byte for byte, `?plan=` matching
|
|
429
|
+
authored plan keys as #8842 made it.
|
|
430
|
+
- **A current shown set** — `lib/offerings-pricing.ts` maps it to a plans
|
|
431
|
+
fragment and an add-ons fragment. The first `pricing` section that is not
|
|
432
|
+
`addons` becomes the Stripe plans, and the first `addons` one the Stripe
|
|
433
|
+
add-ons, each keeping only its authored section copy (eyebrow, headline,
|
|
434
|
+
description and layout knobs), never an authored plan or price. Every other
|
|
435
|
+
`pricing` section is removed; a fragment with no section to replace goes
|
|
436
|
+
after a leading hero. `?plan=` moves the recommendation only for a
|
|
437
|
+
`{plan}`-grammar value (`LOOKUP_KEY_PLAN_PATTERN`), so `?plan=Growth` keeps
|
|
438
|
+
the workspace's own. Copy comes from the `pricingOfferings` namespace.
|
|
439
|
+
- **Anything else** — an expired or empty shown set, `empty`, a non-2xx, an
|
|
440
|
+
unknown body or a throw: every `pricing` section gives way to one
|
|
441
|
+
`contact-form` section holding `ContactFallback` with the site's contact
|
|
442
|
+
email (`readSiteContactEmail()`), in the `contact` namespace's copy. Never
|
|
443
|
+
an error page, and no `Offer` is published.
|
|
444
|
+
|
|
445
|
+
Stripe text reaches the page only as React text and JSON-LD only through
|
|
446
|
+
`renderPage`'s serializer; the title and meta stay the MDX page's.
|
|
447
|
+
|
|
448
|
+
Every Stripe card links `/api/offerings/checkout/<lookup_key>`, which
|
|
449
|
+
`app/api/offerings/checkout/[lookupKey]/route.ts` serves (#9051). Only a
|
|
450
|
+
buyer's page load starts anything: a request that is not a top-level
|
|
451
|
+
navigation (`Sec-Fetch-Mode: navigate`, `Sec-Fetch-Dest: document`), a
|
|
452
|
+
`HEAD`, a prefetch, an RSC fetch or a cross-site load is refused before any
|
|
453
|
+
call. A key `parseLookupKey` refuses, a site without the `commerce` claim, and
|
|
454
|
+
production with no API origin are refused locally too. The route then reads
|
|
455
|
+
the shown set, shared per instance for 15 seconds like the page's own read,
|
|
456
|
+
and goes on only when the site is connected and that set shows the key, so an
|
|
457
|
+
unconnected site never reaches the API's env path. Each visitor address gets 5
|
|
458
|
+
starts per 10 minutes per instance. The route POSTs `/v1/checkout/sessions`
|
|
459
|
+
through `checkout.createSession` with the visitor's `x-forwarded-for`, success
|
|
460
|
+
URL `/purchase/success?session_id={CHECKOUT_SESSION_ID}` and cancel URL
|
|
461
|
+
`/pricing`, both on the origin of `resolveBaseUrl()` and never the request's
|
|
462
|
+
host, and answers a `303` to the `https:` Checkout URL the API returns. Every
|
|
463
|
+
refusal is a `303` to `/pricing` with no body, `Cache-Control: no-store` and
|
|
464
|
+
one `[offerings-checkout] refused reason=<reason>` warning; no response or
|
|
465
|
+
log line carries the key. It sends `OSS_API_INTERNAL_SECRET` as
|
|
466
|
+
`x-internal-auth` only from a site that is not a spoke and only to an
|
|
467
|
+
`https:` or loopback API, so the hub's own `apps/web` must hold the API's
|
|
468
|
+
value when the API sets it. A spoke never holds that secret: it sells only
|
|
469
|
+
when its hub lists the spoke's origin in `AILK_TRUSTED_ORIGINS` and does not
|
|
470
|
+
set `OSS_API_INTERNAL_SECRET`, and otherwise every spoke buy returns to
|
|
471
|
+
`/pricing`.
|
|
472
|
+
|
|
473
|
+
An existing scaffold adopts the new page by bringing
|
|
474
|
+
`lib/offerings-pricing.ts`, `app/api/offerings/checkout/[lookupKey]/route.ts`
|
|
475
|
+
(it sits under `app/api/`, outside the route registry `sync-routes` copies),
|
|
476
|
+
the `pricingOfferings` namespace and an
|
|
477
|
+
`@working-theory/api-client` release that exports `offerings` with it, and
|
|
478
|
+
needs an API (its own `apps/api`, or a spoke's hub) that serves
|
|
479
|
+
`GET /v1/content/shown-offerings` (#8924): an older API answers that path
|
|
480
|
+
non-2xx, which renders the contact email in place of the authored pricing.
|
|
481
|
+
|
|
217
482
|
## Spoke checkout
|
|
218
483
|
|
|
219
|
-
A spoke
|
|
220
|
-
(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
-
|
|
242
|
-
|
|
243
|
-
paid session, `payload.site` set to the checkout stamp (never
|
|
244
|
-
`AILK_LEAD_SITE`), the session id as the key, and a body that must stay
|
|
245
|
-
byte-identical between attempts, because the hub replays only an identical
|
|
246
|
-
body. The payload's last key, `termsAccepted`, is the session's `ailk_terms`
|
|
247
|
-
stamp when the buyer accepted Stripe's terms box, else `null`; the hub writes
|
|
248
|
-
the buyer's checkout consent grant from it (#8778). The hub leaves that key
|
|
249
|
-
out of its replay comparison, so a redelivery across the update still
|
|
250
|
-
replays. Only the two route files may import `lib/checkout.ts` or the package
|
|
251
|
-
(`tests/architecture/idempotency-key-producers.test.ts`, `architecture.yaml`);
|
|
252
|
-
a spoke that signs in through its hub adds a third, its rendered billing
|
|
253
|
-
action, which its own `architecture.yaml` admits. The thank-you page may
|
|
254
|
-
import the package for `readPaidCheckoutEmail` alone, never
|
|
255
|
-
`lib/checkout.ts` (#8757 D13): it reads `session_id`, and for a paid
|
|
484
|
+
**A spoke sells through the Offerings buy route, and only through it.**
|
|
485
|
+
`app/api/offerings/checkout/[lookupKey]/route.ts` (§ Pricing page) is the one
|
|
486
|
+
selling path on every site: the seller's Stripe account is held once per
|
|
487
|
+
Organization on the hub, and the hub authorizes each buy against the
|
|
488
|
+
workspace's shown set. The spoke's legacy env-key checkout — its own buy route
|
|
489
|
+
(`app/api/checkout/[offering]/route.ts`), its Stripe webhook route
|
|
490
|
+
(`app/api/webhooks/stripe/route.ts`), `lib/checkout.ts` (the fulfilment) and
|
|
491
|
+
the generated checkout e2e workflow — is retired (#9142). `create-ailk
|
|
492
|
+
sync-routes --apply` removes them from an existing spoke when they are still
|
|
493
|
+
the copies create-ailk wrote, and names any it leaves.
|
|
494
|
+
|
|
495
|
+
What a spoke that carries `commerce` still gets (#8748), kept current by
|
|
496
|
+
`create-ailk update`; a hub, or a spoke without `commerce`, never gets them:
|
|
497
|
+
|
|
498
|
+
- **The two return pages.** `app/[locale]/checkout/thank-you/page.tsx` and
|
|
499
|
+
`app/[locale]/checkout/unavailable/page.tsx`. Both call `notFound()` unless
|
|
500
|
+
`lib/topology.generated.ts`'s `TOPOLOGY` is `"spoke"`, so this monorepo's
|
|
501
|
+
hub-shaped `apps/web` serves neither.
|
|
502
|
+
- **One data file.** `lib/checkout-config.ts` is the site's stamp,
|
|
503
|
+
`checkoutConfig.site`, and nothing else: no import and no code.
|
|
504
|
+
`create-ailk update` never rewrites it.
|
|
505
|
+
- **One package reader.** The thank-you page may import
|
|
506
|
+
`@working-theory/spoke-checkout` for `readPaidCheckoutEmail` alone
|
|
507
|
+
(`architecture.yaml`, #8757 D13): it reads `session_id`, and for a paid
|
|
256
508
|
session of this site under 24 hours old shows "Open your account" with the
|
|
257
509
|
buyer's email (`accountInviteHref`), else the same link with no email. Each
|
|
258
510
|
visitor gets 10 reads per 10 minutes (`src/lib/rate-limit.ts`); the page
|
|
259
|
-
sends no referrer, renders dynamically and never touches a cookie.
|
|
260
|
-
|
|
261
|
-
|
|
511
|
+
sends no referrer, renders dynamically and never touches a cookie. The read
|
|
512
|
+
admits the spoke's `STRIPE_SECRET_KEY` from `process.env`; that key never
|
|
513
|
+
sells.
|
|
514
|
+
- **The billing action (a spoke that signs in through its hub).** create-ailk
|
|
515
|
+
renders `app/[locale]/(authed)/account/billing/spoke-billing.action.ts`,
|
|
516
|
+
which builds the package's billing reads from `checkoutConfig.site` and
|
|
517
|
+
`resolveBaseUrl()`; the spoke's own `architecture.yaml` admits it as the
|
|
518
|
+
package's other importer.
|
|
519
|
+
- **No `Idempotency-Key`.** No `apps/web` file sends one
|
|
520
|
+
(`tests/architecture/idempotency-key-producers.test.ts`): the fulfilment that
|
|
521
|
+
did is retired with the legacy checkout.
|
|
262
522
|
|
|
263
523
|
## The same-origin relay (`/v1/*`)
|
|
264
524
|
|
|
@@ -286,7 +546,29 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
|
|
|
286
546
|
- Don't reach into `apps/api` or `apps/mcp` from `apps/web`. They're peer apps — talk via HTTP/MCP.
|
|
287
547
|
- Don't commit images to `screenshots/` — only the `.gitkeep` is tracked.
|
|
288
548
|
|
|
289
|
-
Last updated: 2026-10-
|
|
549
|
+
Last updated: 2026-10-20 — § Magnet pages: the trial magnet's pair, `/trial` (its button to `/trial/request` through `renderMagnetLanding`'s `startPath`) and the chrome-free `/trial/request` walk with its own `<h1>` fallback (#9287 C2, DD10) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `app/[locale]/trial/{page,request/page}.tsx` and `lib/magnet-routes.ts`.
|
|
550
|
+
|
|
551
|
+
Prior: 2026-10-19 — § Magnet pages: `/careers/apply` renders the contact email under its own `<h1>` when its flow is absent or the hub cannot answer; `readMagnet`'s `not-configured` kind 404s an unknown `/reports/<slug>` (and its `/start`) on a site with no hub to ask, while a configured hub that is down keeps #8848 D6's contact email (#9219) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `app/[locale]/careers/apply/page.tsx`, `app/[locale]/reports/deliverable-page.tsx` and `app/[locale]/reports/[slug]/{page,start/page}.tsx`.
|
|
552
|
+
|
|
553
|
+
Prior: 2026-10-18 — § Spoke checkout: the Offerings buy route is the one selling path, and the legacy env-key buy route, webhook route, `lib/checkout.ts` and checkout e2e workflow are retired (#9142) — verified against `app/api/offerings/checkout/[lookupKey]/route.ts`, `app/[locale]/checkout/*/page.tsx`, `lib/checkout-config.ts` and create-ailk's `spoke-checkout.ts` and `sync-routes.ts`.
|
|
554
|
+
|
|
555
|
+
Prior: 2026-10-18 — § Magnet pages: the recommender's landing on `/pricing?plan=` (#9071) — verified against `app/[locale]/flows/[slug]/results/[payload]/page.tsx` and its `landing.test.tsx`.
|
|
556
|
+
|
|
557
|
+
Prior: 2026-10-18 — § Magnet pages: the deliverable pages at `/reports/<slug>`, their ending, the file proxy and the `/downloads` redirects (#8848) — verified against `app/[locale]/reports/{deliverable-page.tsx,[slug]/page.tsx,[slug]/start/page.tsx,[slug]/file/route.ts}`, `app/[locale]/flows/[slug]/{magnet-page,FlowStepperClient}.tsx`, `app/[locale]/downloads/` and `lib/magnet-routes.ts`.
|
|
558
|
+
|
|
559
|
+
Prior: 2026-10-18 — § Contact page and § Newsletter, unsubscribe and the footer signup: the in-page form shape — every visible fact from the Contact form and Newsletter magnets (#8847) — verified against `lib/in-page-magnet.ts`, `app/[locale]/{contact,newsletter,unsubscribe}/page.tsx`, `lib/newsletter.actions.ts` and `app/[locale]/layout.tsx`.
|
|
560
|
+
|
|
561
|
+
Prior: 2026-10-18 — § Careers: the searchable /careers, the posting's Apply button and the Free apply page (#8849) — verified against `app/[locale]/careers/{page,[slug]/page,[slug]/[action]/page}.tsx` and `lib/posting-title.ts`; § Pricing page: the Offerings buy route `app/api/offerings/checkout/[lookupKey]/route.ts` starts the hosted Checkout for a shown key and returns every refusal to `/pricing`, and the spoke precondition (#9051) — verified against the route, its suite and `lib/offerings-pricing.ts`.
|
|
562
|
+
|
|
563
|
+
Prior: 2026-10-18 — § Meeting pages: the booking pages carry the Cal.com CSP (`SCHEDULING_CSP`, applied by `middleware.ts` on booking paths only) and `scheduler-embed` forwards `gapAbove` (#8843 D12, D13) — verified against `security-headers.config.ts`, `middleware.ts` and `packages/ui/src/sections/SchedulerEmbed.tsx`.
|
|
564
|
+
|
|
565
|
+
Prior: 2026-10-18 — § Meeting pages: the booking pages read the workspace's meetings through one builder, the chrome-free booking branch and its module flag (#8843) — verified against `lib/meetings.tsx`, `app/[locale]/meet/`, `app/[locale]/private/page.tsx`, `lib/auth-routes.ts` and `app/[locale]/layout.tsx`.
|
|
566
|
+
|
|
567
|
+
Prior: 2026-10-18 — § Magnet pages: the standard magnet page pair, its walk predicate, ending, hub-down fallback and `add-route magnet` (#8840) — verified against `app/[locale]/flows/[slug]/magnet-page.tsx`, `FlowStepperClient.tsx`, `lib/magnet-routes.ts`, `app/[locale]/layout.tsx` and `app/[locale]/careers/{apply,[slug]/apply}/page.tsx`.
|
|
568
|
+
|
|
569
|
+
Prior: 2026-10-18 — § Pricing page: `/pricing` renders the Offerings shown set, the contact email when there is none to sell, and today's page for an unconnected site (#8927) — verified against `app/[locale]/pricing/page.tsx` and `lib/offerings-pricing.ts`.
|
|
570
|
+
|
|
571
|
+
Prior: 2026-10-16 — § Spoke checkout: the purchase lead's `termsAccepted` key, the stamp of the terms the buyer accepted, from which the hub writes the checkout consent grant (#8778) — verified against `lib/checkout.ts` and `packages/spoke-checkout/src/webhook.ts`.
|
|
290
572
|
|
|
291
573
|
Prior: 2026-10-16 — § Spoke checkout: the thank-you page reads the paid session's email for "Open your account" (#8757 D13, #8800) — verified against `app/[locale]/checkout/thank-you/page.tsx` and `packages/spoke-checkout/src/paid-email.ts`.
|
|
292
574
|
|
package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* account, and its Server Action `acceptAccountAgreementsAction`.
|
|
5
5
|
*/
|
|
6
6
|
import { consentStatementText } from "@working-theory/ui/blocks";
|
|
7
|
-
import {
|
|
7
|
+
import { ACCOUNT_TERMS_STATEMENT } from "@working-theory/validation";
|
|
8
8
|
import {
|
|
9
9
|
fireEvent,
|
|
10
10
|
render,
|
|
@@ -56,7 +56,7 @@ const LABELS = {
|
|
|
56
56
|
|
|
57
57
|
const AGREEMENT: AgreementsInterstitialAgreement = {
|
|
58
58
|
id: "account_terms",
|
|
59
|
-
statement:
|
|
59
|
+
statement: ACCOUNT_TERMS_STATEMENT,
|
|
60
60
|
acceptedVersion: null,
|
|
61
61
|
};
|
|
62
62
|
|
|
@@ -132,7 +132,7 @@ describe("AgreementsInterstitial — the statement renders with its links (AC-5,
|
|
|
132
132
|
"account-agreements-statement-account_terms",
|
|
133
133
|
);
|
|
134
134
|
expect(statement.textContent).toBe(
|
|
135
|
-
consentStatementText(
|
|
135
|
+
consentStatementText(ACCOUNT_TERMS_STATEMENT),
|
|
136
136
|
);
|
|
137
137
|
expect(statement.textContent).not.toContain("[");
|
|
138
138
|
expect(statement.textContent).not.toContain("](");
|
|
@@ -123,7 +123,7 @@ jest.mock("next/navigation", () => ({
|
|
|
123
123
|
|
|
124
124
|
import { usePhoneTab } from "@working-theory/templates/account";
|
|
125
125
|
import { consentStatementText } from "@working-theory/ui/blocks";
|
|
126
|
-
import {
|
|
126
|
+
import { ACCOUNT_TERMS_STATEMENT } from "@working-theory/validation";
|
|
127
127
|
|
|
128
128
|
import AccountLayout from "../layout";
|
|
129
129
|
import InboxPage from "../page";
|
|
@@ -152,7 +152,7 @@ function agreementsAnswer(entry: { acceptedVersion: number | null; current: bool
|
|
|
152
152
|
agreements: [
|
|
153
153
|
{
|
|
154
154
|
id: "account_terms",
|
|
155
|
-
statement:
|
|
155
|
+
statement: ACCOUNT_TERMS_STATEMENT,
|
|
156
156
|
policyVersion: 2,
|
|
157
157
|
acceptedVersion: entry.acceptedVersion,
|
|
158
158
|
acceptedAt: entry.acceptedVersion === null ? null : "2026-09-01T00:00:00.000Z",
|
|
@@ -694,7 +694,7 @@ describe("the agreements interstitial (#8765 D7)", () => {
|
|
|
694
694
|
await renderLayout();
|
|
695
695
|
expectInterstitialAlone("Please accept our terms");
|
|
696
696
|
const statement = screen.getByTestId("account-agreements-statement-account_terms");
|
|
697
|
-
expect(statement.textContent).toBe(consentStatementText(
|
|
697
|
+
expect(statement.textContent).toBe(consentStatementText(ACCOUNT_TERMS_STATEMENT));
|
|
698
698
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
699
699
|
expect.any(String),
|
|
700
700
|
{ event: "account-agreements", outcome: "read_failed" },
|