create-ailk 0.1.0 → 0.2.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 +124 -3
- package/component-catalog.md +456 -0
- package/dist/cli.js +92 -16
- package/dist/component-catalog.d.ts +11 -0
- package/dist/component-catalog.js +39 -0
- package/dist/copy.d.ts +7 -0
- package/dist/copy.js +24 -0
- package/dist/derive-architecture.d.ts +13 -0
- package/dist/derive-architecture.js +238 -0
- package/dist/index.d.ts +7 -2
- package/dist/index.js +19 -2
- package/dist/lib/extract-module-bundle.d.ts +16 -0
- package/dist/lib/extract-module-bundle.js +184 -0
- package/dist/lib/fetch-module.d.ts +55 -0
- package/dist/lib/fetch-module.js +125 -0
- package/dist/lib/module-license-gate.d.ts +48 -0
- package/dist/lib/module-license-gate.js +67 -0
- package/dist/lib/module-tier.d.ts +35 -0
- package/dist/lib/module-tier.js +37 -0
- package/dist/module-architecture.d.ts +175 -0
- package/dist/module-architecture.js +663 -0
- package/dist/parse-args.d.ts +47 -1
- package/dist/parse-args.js +143 -2
- package/dist/programmatic.d.ts +160 -3
- package/dist/programmatic.js +196 -7
- package/dist/surfaces.d.ts +211 -0
- package/dist/surfaces.js +532 -0
- package/dist/sync-routes.d.ts +92 -0
- package/dist/sync-routes.js +350 -0
- package/package.json +12 -5
- package/templates/.claude/rules/architecture.md +6 -2
- package/templates/.claude/skills/README.md +9 -9
- package/templates/.claude/skills/add-schema/SKILL.md +14 -7
- package/templates/.claude/skills/add-schema/references/schema-types.md +109 -6
- package/templates/.claude/skills/provision-config/SKILL.md +1 -1
- package/templates/.claude/skills/scaffold-commerce/SKILL.md +498 -1
- package/templates/.claude/skills/scaffold-commerce/templates/checkout-route.template.ts +370 -0
- package/templates/.claude/skills/scaffold-commerce/templates/invoice-route.template.ts +376 -0
- package/templates/.claude/skills/scaffold-commerce/templates/payment-link-route.template.ts +471 -0
- package/templates/.claude/skills/scaffold-commerce/templates/portal-route.template.ts +365 -0
- package/templates/.claude/skills/scaffold-commerce/templates/stripe-config.template.ts +60 -0
- package/templates/.claude/skills/scaffold-commerce/templates/subscription-route.template.ts +446 -0
- package/templates/.claude/skills/suggest-site-pages/references/categories.md +4 -4
- package/templates/.env.example +126 -2
- package/templates/CLAUDE.md +11 -5
- package/templates/README.md +1 -1
- package/templates/apps/api/.env.example +38 -0
- package/templates/apps/api/CLAUDE.md +78 -1
- package/templates/apps/api/LICENSE +201 -0
- package/templates/apps/api/api/index.ts +6 -141
- package/templates/apps/api/jest.config.cjs +16 -0
- package/templates/apps/api/package.json +22 -6
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +141 -0
- package/templates/apps/api/src/__tests__/server.test.ts +46 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +211 -0
- package/templates/apps/api/src/bin/abandon-sweep.ts +42 -0
- package/templates/apps/api/src/bin/deliverable-resend.ts +144 -0
- package/templates/apps/api/src/bin/deliverable-upload.ts +114 -0
- package/templates/apps/api/src/bin/followup-sweep.ts +44 -0
- package/templates/apps/api/src/bin/listing-csv-sweep.ts +46 -0
- package/templates/apps/api/src/bin/seed-presets.ts +58 -0
- package/templates/apps/api/src/bin/seed-waitlist-experiments.ts +104 -0
- package/templates/apps/api/src/bin/session-retention-sweep.ts +46 -0
- package/templates/apps/api/src/cli.ts +5 -11
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/config/modules.ts +43 -0
- package/templates/apps/api/src/lib/__mocks__/prisma-client.js +11 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +241 -0
- package/templates/apps/api/src/lib/__tests__/lead-capture-store.test.ts +2 -2
- package/templates/apps/api/src/lib/site.ts +16 -0
- package/templates/apps/api/src/lib/stripe.ts +22 -0
- package/templates/apps/api/src/lib/tenant-db.ts +225 -0
- package/templates/apps/api/src/lib/ws-token.ts +91 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +7 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +91 -4
- package/templates/apps/api/src/middleware/auth.ts +43 -8
- package/templates/apps/api/src/middleware/rate-limit.ts +78 -3
- package/templates/apps/api/src/middleware/tenant.ts +99 -0
- package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +92 -0
- package/templates/apps/api/src/openapi/spec.ts +312 -3
- package/templates/apps/api/src/routes/aeo/__tests__/index.test.ts +175 -0
- package/templates/apps/api/src/routes/aeo/index.ts +107 -0
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +5 -2
- package/templates/apps/api/src/routes/auth/__tests__/tokens.test.ts +22 -3
- package/templates/apps/api/src/routes/auth/tokens.ts +13 -1
- package/templates/apps/api/src/routes/billing/__tests__/portal.test.ts +450 -0
- package/templates/apps/api/src/routes/billing/__tests__/read.test.ts +262 -0
- package/templates/apps/api/src/routes/billing/__tests__/usage.test.ts +436 -0
- package/templates/apps/api/src/routes/billing/index.ts +36 -0
- package/templates/apps/api/src/routes/billing/portal.ts +182 -0
- package/templates/apps/api/src/routes/billing/read.ts +75 -0
- package/templates/apps/api/src/routes/billing/usage.ts +153 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +99 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -8
- package/templates/apps/api/src/routes/content/create.ts +1 -0
- package/templates/apps/api/src/routes/content/delete.ts +1 -0
- package/templates/apps/api/src/routes/content/index.ts +42 -6
- package/templates/apps/api/src/routes/content/update.ts +1 -0
- package/templates/apps/api/src/routes/deliverables/__tests__/index.test.ts +393 -0
- package/templates/apps/api/src/routes/deliverables/index.ts +200 -0
- package/templates/apps/api/src/routes/flow-checkouts/__tests__/index.test.ts +443 -0
- package/templates/apps/api/src/routes/flow-checkouts/index.ts +82 -0
- package/templates/apps/api/src/routes/flows/README.md +147 -0
- package/templates/apps/api/src/routes/flows/__tests__/index.test.ts +752 -0
- package/templates/apps/api/src/routes/flows/__tests__/recommender.test.ts +671 -0
- package/templates/apps/api/src/routes/flows/index.ts +202 -0
- package/templates/apps/api/src/routes/flows/recommender.ts +443 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +438 -31
- package/templates/apps/api/src/routes/leads/__tests__/track.test.ts +10 -10
- package/templates/apps/api/src/routes/leads/index.ts +43 -9
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +558 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/copy-edit.test.ts +815 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +869 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me-route-precedence.test.ts +221 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +468 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +424 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +707 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +375 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +467 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/tenant-isolation.test.ts +566 -0
- package/templates/apps/api/src/routes/project-listings/get.ts +70 -0
- package/templates/apps/api/src/routes/project-listings/index.ts +120 -0
- package/templates/apps/api/src/routes/project-listings/list.ts +47 -0
- package/templates/apps/api/src/routes/project-listings/me.ts +76 -0
- package/templates/apps/api/src/routes/project-listings/patch-copy.ts +222 -0
- package/templates/apps/api/src/routes/project-listings/patch-draft.ts +97 -0
- package/templates/apps/api/src/routes/project-listings/patch.ts +136 -0
- package/templates/apps/api/src/routes/project-listings/public.ts +52 -0
- package/templates/apps/api/src/routes/project-listings/respond.ts +37 -0
- package/templates/apps/api/src/routes/project-listings/resume-token.ts +22 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +106 -0
- package/templates/apps/api/src/routes/project-listings/submit.ts +122 -0
- package/templates/apps/api/src/routes/schedule/__tests__/index.test.ts +490 -0
- package/templates/apps/api/src/routes/schedule/index.ts +249 -0
- package/templates/apps/api/src/routes/slack/__tests__/actions.test.ts +385 -0
- package/templates/apps/api/src/routes/slack/actions.ts +177 -0
- package/templates/apps/api/src/routes/slack/index.ts +38 -0
- package/templates/apps/api/src/routes/test/auth/__tests__/session.test.ts +16 -6
- package/templates/apps/api/src/routes/test/auth/session.ts +8 -6
- package/templates/apps/api/src/routes/waitlist-experiments/__tests__/tenant-isolation.test.ts +577 -0
- package/templates/apps/api/src/routes/waitlist-experiments/comparison.ts +78 -0
- package/templates/apps/api/src/routes/waitlist-experiments/index.ts +41 -0
- package/templates/apps/api/src/routes/waitlist-experiments/list.ts +64 -0
- package/templates/apps/api/src/routes/waitlist-experiments/respond.ts +42 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signup.ts +84 -0
- package/templates/apps/api/src/routes/waitlist-experiments/signups.ts +119 -0
- package/templates/apps/api/src/routes/waitlist-signups/__tests__/index.test.ts +238 -0
- package/templates/apps/api/src/routes/waitlist-signups/index.ts +101 -0
- package/templates/apps/api/src/routes/webhooks/README.md +35 -12
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +315 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +270 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +248 -20
- package/templates/apps/api/src/routes/workspaces/__tests__/config-tenant-isolation.test.ts +356 -0
- package/templates/apps/api/src/routes/workspaces/__tests__/tenant-isolation.test.ts +671 -0
- package/templates/apps/api/src/routes/workspaces/config.ts +125 -0
- package/templates/apps/api/src/routes/workspaces/index.ts +194 -0
- package/templates/apps/api/src/routes/workspaces/sessions.ts +218 -0
- package/templates/apps/api/src/server.ts +307 -7
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +489 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/answer-distributions.test.ts +132 -0
- package/templates/apps/api/src/services/__tests__/consent-migration.test.ts +65 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +282 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +523 -0
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +359 -0
- package/templates/apps/api/src/services/__tests__/entitlements.test.ts +142 -0
- package/templates/apps/api/src/services/__tests__/flow-aggregate.test.ts +425 -0
- package/templates/apps/api/src/services/__tests__/flow-engine.test.ts +2840 -0
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +393 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +8 -8
- package/templates/apps/api/src/services/__tests__/listing-csv-sweep.test.ts +560 -0
- package/templates/apps/api/src/services/__tests__/listing-promotion.test.ts +684 -0
- package/templates/apps/api/src/services/__tests__/playbook-compile.test.ts +406 -0
- package/templates/apps/api/src/services/__tests__/playbook-render.test.ts +290 -0
- package/templates/apps/api/src/services/__tests__/project-listing-decision.test.ts +736 -0
- package/templates/apps/api/src/services/__tests__/project-listing-flow.test.ts +475 -0
- package/templates/apps/api/src/services/__tests__/project-listing-issue.test.ts +340 -0
- package/templates/apps/api/src/services/__tests__/recommender-capture.test.ts +983 -0
- package/templates/apps/api/src/services/__tests__/slack-notify.test.ts +278 -0
- package/templates/apps/api/src/services/__tests__/tenant-context-cascade.test.ts +71 -0
- package/templates/apps/api/src/services/__tests__/tenant-context.test.ts +379 -0
- package/templates/apps/api/src/services/__tests__/usage-aggregation.test.ts +941 -0
- package/templates/apps/api/src/services/__tests__/usage-credits.test.ts +588 -0
- package/templates/apps/api/src/services/__tests__/usage-metering.test.ts +768 -0
- package/templates/apps/api/src/services/__tests__/waitlist-dashboard.test.ts +1314 -0
- package/templates/apps/api/src/services/__tests__/waitlist-experiments.test.ts +341 -0
- package/templates/apps/api/src/services/__tests__/waitlist-followup.test.ts +567 -0
- package/templates/apps/api/src/services/__tests__/waitlist-scoring.test.ts +474 -0
- package/templates/apps/api/src/services/__tests__/waitlist-signups.test.ts +354 -0
- package/templates/apps/api/src/services/abandon-email.ts +307 -0
- package/templates/apps/api/src/services/aeo-score.ts +288 -0
- package/templates/apps/api/src/services/answer-distributions.ts +138 -0
- package/templates/apps/api/src/services/consent.ts +236 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +523 -0
- package/templates/apps/api/src/services/entitlements.ts +102 -0
- package/templates/apps/api/src/services/flow-aggregate.ts +232 -0
- package/templates/apps/api/src/services/flow-checkouts.ts +123 -0
- package/templates/apps/api/src/services/flow-engine.ts +1296 -0
- package/templates/apps/api/src/services/lead-promotion.ts +176 -0
- package/templates/apps/api/src/services/lead-routing.ts +3 -3
- package/templates/apps/api/src/services/listing-config.ts +102 -0
- package/templates/apps/api/src/services/listing-csv-sweep.ts +455 -0
- package/templates/apps/api/src/services/listing-promotion.ts +342 -0
- package/templates/apps/api/src/services/playbook-compile.ts +398 -0
- package/templates/apps/api/src/services/playbook-render.ts +263 -0
- package/templates/apps/api/src/services/project-listing-decision.ts +510 -0
- package/templates/apps/api/src/services/project-listing-flow.ts +426 -0
- package/templates/apps/api/src/services/project-listing-issue.ts +251 -0
- package/templates/apps/api/src/services/project-listings.ts +1472 -0
- package/templates/apps/api/src/services/recommender-capture.ts +835 -0
- package/templates/apps/api/src/services/scheduling/cal-provider.ts +392 -0
- package/templates/apps/api/src/services/scheduling/index.ts +63 -0
- package/templates/apps/api/src/services/scheduling/types.ts +88 -0
- package/templates/apps/api/src/services/slack-notify.ts +240 -0
- package/templates/apps/api/src/services/tenant-context.ts +451 -0
- package/templates/apps/api/src/services/usage-aggregation.ts +516 -0
- package/templates/apps/api/src/services/usage-credits.ts +340 -0
- package/templates/apps/api/src/services/usage-metering.ts +699 -0
- package/templates/apps/api/src/services/waitlist-dashboard.ts +947 -0
- package/templates/apps/api/src/services/waitlist-experiments.ts +213 -0
- package/templates/apps/api/src/services/waitlist-followup.ts +486 -0
- package/templates/apps/api/src/services/waitlist-scoring.ts +166 -0
- package/templates/apps/api/src/services/waitlist-signups.ts +165 -0
- package/templates/apps/api/src/vercel-handler.ts +165 -0
- package/templates/apps/api/tsconfig.json +0 -12
- package/templates/apps/mcp/CLAUDE.md +19 -9
- package/templates/apps/mcp/LICENSE +201 -0
- package/templates/apps/mcp/__tests__/create_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/delete_page.test.ts +1 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +105 -0
- package/templates/apps/mcp/__tests__/parity.test.ts +70 -6
- package/templates/apps/mcp/__tests__/request_callback.test.ts +4 -4
- package/templates/apps/mcp/__tests__/schedule_tools.test.ts +242 -0
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +4 -4
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +4 -4
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +47 -0
- package/templates/apps/mcp/__tests__/update_page.test.ts +1 -0
- package/templates/apps/mcp/package.json +6 -4
- package/templates/apps/mcp/src/config/modules.ts +37 -0
- package/templates/apps/mcp/src/server.ts +109 -18
- package/templates/apps/mcp/src/tools/capture_lead.ts +1 -1
- package/templates/apps/mcp/src/tools/create_booking.ts +58 -0
- package/templates/apps/mcp/src/tools/create_page.ts +1 -0
- package/templates/apps/mcp/src/tools/delete_page.ts +1 -0
- package/templates/apps/mcp/src/tools/get_event_meta.ts +55 -0
- package/templates/apps/mcp/src/tools/get_flow.ts +61 -0
- package/templates/apps/mcp/src/tools/index.ts +25 -0
- package/templates/apps/mcp/src/tools/list_availability.ts +67 -0
- package/templates/apps/mcp/src/tools/purchase.ts +3 -0
- package/templates/apps/mcp/src/tools/submit_flow_step.ts +83 -0
- package/templates/apps/mcp/src/tools/update_page.ts +1 -0
- package/templates/apps/mcp/tsconfig.json +1 -9
- package/templates/apps/web/.env.example +44 -0
- package/templates/apps/web/CLAUDE.md +28 -0
- package/templates/apps/web/LICENSE +201 -0
- package/templates/apps/web/__tests__/__mocks__/code-highlight.jest-stub.ts +24 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +33 -0
- package/templates/apps/web/__tests__/__mocks__/next-intl.ts +18 -0
- package/templates/apps/web/__tests__/__mocks__/server-only.ts +13 -0
- package/templates/apps/web/__tests__/__mocks__/style-mock.js +4 -0
- package/templates/apps/web/__tests__/__mocks__/vercel-analytics-server.ts +9 -0
- package/templates/apps/web/__tests__/jest-support/import-meta-transformer.cjs +90 -0
- package/templates/apps/web/__tests__/setup.ts +22 -32
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +6 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +8 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +260 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +263 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +39 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +54 -0
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/page.tsx +309 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/[experimentId]/signups/[leadId]/page.tsx +149 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/comparison.test.tsx +273 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/gate.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/list.test.tsx +171 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/__tests__/signup.test.tsx +179 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ExperimentsTable.tsx +116 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ForbiddenState.tsx +24 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/OfferFunnelTable.tsx +66 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/RollupTicker.tsx +61 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/ScoreTrace.tsx +114 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/SignupsTable.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/WaitlistComparisonTabs.tsx +85 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/components/status-badges.tsx +67 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/format.ts +111 -0
- package/templates/apps/web/app/[locale]/(authed)/waitlist/page.tsx +179 -0
- package/templates/apps/web/app/[locale]/about/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/blog/[[...slug]]/page.tsx +6 -1
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/case-studies/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/case-studies/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/clients/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/contact/ContactForm.tsx +21 -1
- package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +188 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +242 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +29 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/intake-analytics.test.ts +51 -0
- package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +39 -15
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +21 -3
- package/templates/apps/web/app/[locale]/contact/intake-analytics.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/intake-script.ts +66 -0
- package/templates/apps/web/app/[locale]/contact/page.tsx +14 -4
- package/templates/apps/web/app/[locale]/courses/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/courses/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/docs/[[...slug]]/page.tsx +1 -0
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/downloads/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/events/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/faq/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +265 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.permalink.test.tsx +445 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.test.tsx +341 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/checkout.actions.test.ts +287 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/flow.actions.test.ts +302 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +128 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/checkout.actions.ts +254 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +144 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +108 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +532 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsEmailStep.tsx +121 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/ResultsClient.checkout.test.tsx +457 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/page.test.tsx +396 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +114 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +52 -0
- package/templates/apps/web/app/[locale]/how-to/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/integrations/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/integrations/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/launch-service/page.tsx +14 -1
- package/templates/apps/web/app/[locale]/layout.tsx +127 -16
- package/templates/apps/web/app/[locale]/page.tsx +10 -1
- package/templates/apps/web/app/[locale]/partners/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/partners/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/products/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/products/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/projects/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +10 -0
- package/templates/apps/web/app/[locale]/projects/apply/ListingApplicationClient.tsx +110 -0
- package/templates/apps/web/app/[locale]/projects/apply/__tests__/ListingApplicationClient.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/projects/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/resources/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +12 -2
- package/templates/apps/web/app/[locale]/resources/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/schedule/__tests__/page.test.tsx +117 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/__tests__/page.test.tsx +96 -0
- package/templates/apps/web/app/[locale]/schedule/confirmed/page.tsx +92 -0
- package/templates/apps/web/app/[locale]/schedule/page.tsx +82 -0
- package/templates/apps/web/app/[locale]/services/[slug]/page.tsx +9 -0
- package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/services/page.tsx +9 -1
- package/templates/apps/web/app/[locale]/sign-in/__tests__/next-path.test.ts +31 -0
- package/templates/apps/web/app/[locale]/sign-in/__tests__/page.test.tsx +152 -0
- package/templates/apps/web/app/[locale]/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/sign-in/page.tsx +83 -0
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +123 -0
- package/templates/apps/web/app/[locale]/software/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/tech-articles/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/testimonials/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +93 -0
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +48 -0
- package/templates/apps/web/app/[locale]/use-cases/__tests__/page-renderer-migration.test.tsx +11 -1
- package/templates/apps/web/app/[locale]/use-cases/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
- package/templates/apps/web/app/globals.css +35 -0
- package/templates/apps/web/app/llms.txt/route.ts +2 -1
- package/templates/apps/web/app/og/route.tsx +10 -2
- package/templates/apps/web/auth.ts +8 -0
- package/templates/apps/web/components/sign-out-trigger.tsx +42 -0
- package/templates/apps/web/content/en/blog/README.txt +25 -0
- package/templates/apps/web/content/en/docs/index.mdx +28 -0
- package/templates/apps/web/jest.config.cjs +90 -27
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +119 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +112 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +499 -0
- package/templates/apps/web/lib/account-config.ts +13 -0
- package/templates/apps/web/lib/server-api.ts +85 -0
- package/templates/apps/web/lib/site-brand.tsx +121 -0
- package/templates/apps/web/lib/site-theme.ts +74 -0
- package/templates/apps/web/messages/en.json +275 -7
- package/templates/apps/web/middleware.ts +11 -3
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/apps/web/package.json +17 -7
- package/templates/apps/web/postcss.config.mjs +23 -0
- package/templates/apps/web/public/.well-known/security.txt +3 -3
- package/templates/apps/web/src/lib/routes.ts +15 -2
- package/templates/apps/web/tsconfig.json +1 -16
- package/templates/content/_site.mdx +36 -25
- package/templates/content/en/AboutPage/about.mdx +24 -14
- package/templates/content/en/ContactPage/contact.mdx +23 -14
- package/templates/content/en/FAQPage/faq.mdx +10 -392
- package/templates/content/en/HomePage/home.mdx +14 -285
- package/templates/database/CHANGELOG.md +443 -0
- package/templates/database/LICENSE +201 -0
- package/templates/database/README.md +3 -1
- package/templates/database/__tests__/flows-listing-diagnostic.test.ts +392 -0
- package/templates/database/__tests__/flows-presets.test.ts +173 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +146 -0
- package/templates/database/__tests__/seed.test.ts +88 -0
- package/templates/database/__tests__/user-cascade.test.ts +19 -4
- package/templates/database/auth/schema.prisma +521 -0
- package/templates/database/config/schema.prisma +234 -0
- package/templates/database/content/schema.prisma +263 -0
- package/templates/database/flows/listing-diagnostic.ts +182 -0
- package/templates/database/flows/presets.ts +315 -0
- package/templates/database/inbox/__tests__/schema.test.ts +4 -0
- package/templates/database/inbox/schema.prisma +574 -3
- package/templates/database/jest.config.cjs +8 -0
- package/templates/database/migrations/20260712215647_add_multi_tenant_tenancy/migration.sql +201 -0
- package/templates/database/migrations/20260713143320_reanchor_site_config_to_workspace/migration.sql +67 -0
- package/templates/database/migrations/20260713174000_add_audit_logs/migration.sql +46 -0
- package/templates/database/migrations/20260713190000_drop_telemetry_records/migration.sql +25 -0
- package/templates/database/migrations/20260713192000_add_uuidv7_function/migration.sql +49 -0
- package/templates/database/migrations/20260713192429_prefixed_uuid_entity_ids/migration.sql +197 -0
- package/templates/database/migrations/20260713200000_add_org_billing/migration.sql +32 -0
- package/templates/database/migrations/20260714000000_add_lead_promotion_fields/migration.sql +22 -0
- package/templates/database/migrations/20260714010000_add_usage_billing_content_models/migration.sql +151 -0
- package/templates/database/migrations/20260714020000_add_usage_billing_auth_config_models/migration.sql +223 -0
- package/templates/database/migrations/20260714030000_credit_balance_non_negative/migration.sql +22 -0
- package/templates/database/migrations/20260714030000_widen_usage_billing_report_aggregates/migration.sql +32 -0
- package/templates/database/migrations/20260810233516_add_flow_engine_models/migration.sql +40 -0
- package/templates/database/migrations/20260811020000_add_flow_session_abandon_email_columns/migration.sql +4 -0
- package/templates/database/migrations/20260811030000_add_flow_session_abandon_email_index/migration.sql +2 -0
- package/templates/database/migrations/20260811040000_add_flow_session_segment/migration.sql +2 -0
- package/templates/database/migrations/20260811050000_add_deliverable_models/migration.sql +29 -0
- package/templates/database/migrations/20260812000000_add_deliverable_grant_send_columns/migration.sql +6 -0
- package/templates/database/migrations/20260902222948_add_waitlist_experiment_models/migration.sql +39 -0
- package/templates/database/migrations/20260902224500_add_waitlist_id_format_checks/migration.sql +24 -0
- package/templates/database/migrations/20260903004112_add_flow_session_flags_aeo_score/migration.sql +3 -0
- package/templates/database/migrations/20260903010000_add_playbook_instance/migration.sql +33 -0
- package/templates/database/migrations/20260904000000_add_waitlist_followup_send/migration.sql +30 -0
- package/templates/database/migrations/20260905230000_add_flow_checkout/migration.sql +47 -0
- package/templates/database/migrations/20260906000000_add_flow_checkout_idempotency/migration.sql +16 -0
- package/templates/database/migrations/20260907120000_add_project_listing/migration.sql +47 -0
- package/templates/database/migrations/20260907130000_add_project_listing_slack_columns/migration.sql +10 -0
- package/templates/database/migrations/20260907180000_add_listing_csv_send/migration.sql +26 -0
- package/templates/database/migrations/20260909120000_project_listings_platform/migration.sql +90 -0
- package/templates/database/migrations/20260909180000_listing_draft_and_fields/migration.sql +174 -0
- package/templates/database/migrations/20260910120000_listing_public_site_key/migration.sql +14 -0
- package/templates/database/migrations/20260911120000_listing_copy_edit_audit/migration.sql +25 -0
- package/templates/database/migrations/20260911140000_listing_structured_address/migration.sql +32 -0
- package/templates/database/migrations/20260911180000_consent_grants/migration.sql +71 -0
- package/templates/database/migrations/20260911200000_listing_site_answers/migration.sql +30 -0
- package/templates/database/migrations/20260912120000_listing_owner_link/migration.sql +49 -0
- package/templates/database/ops/schema.prisma +27 -0
- package/templates/database/package.json +42 -4
- package/templates/database/prisma.config.ts +1 -1
- package/templates/database/schema.prisma +19 -435
- package/templates/database/seed-test.ts +14 -4
- package/templates/database/seed.ts +148 -0
- package/templates/database/tenancy.ts +45 -0
- package/templates/database/tsconfig.flows.json +15 -0
- package/templates/database/tsconfig.json +7 -1
- package/templates/database/tsconfig.tenancy.json +14 -0
- package/templates/package.json +30 -55
- package/templates/pnpm-workspace.yaml +0 -3
- package/templates/project.yaml +1 -1
- package/templates/tsconfig.json +1 -37
- package/templates/apps/web/__tests__/auth/adversarial/cookie-attributes.test.ts +0 -94
- package/templates/apps/web/__tests__/auth/adversarial/oauth-state-parameter.test.ts +0 -75
- package/templates/apps/web/__tests__/auth/adversarial/redirect-uri-allowlist.test.ts +0 -82
- package/templates/apps/web/__tests__/auth/env-helpers.test.ts +0 -168
- package/templates/apps/web/__tests__/authority-list-routes.test.tsx +0 -208
- package/templates/apps/web/__tests__/better-stack.test.ts +0 -66
- package/templates/apps/web/__tests__/blog-dir-resolution.test.ts +0 -87
- package/templates/apps/web/__tests__/blog-source-populated.test.ts +0 -130
- package/templates/apps/web/__tests__/data-testid-coverage.test.tsx +0 -60
- package/templates/apps/web/__tests__/google-analytics.test.tsx +0 -104
- package/templates/apps/web/__tests__/i18n/middleware.test.ts +0 -119
- package/templates/apps/web/__tests__/i18n/routing.test.ts +0 -114
- package/templates/apps/web/__tests__/i18n/translations.test.ts +0 -191
- package/templates/apps/web/__tests__/json-ld.test.ts +0 -98
- package/templates/apps/web/__tests__/knowledge-events-routes.test.tsx +0 -580
- package/templates/apps/web/__tests__/layout-stealth.test.ts +0 -39
- package/templates/apps/web/__tests__/lighthouse-fixtures/improvement-image-shrink.tsx +0 -103
- package/templates/apps/web/__tests__/lighthouse-fixtures/override-fixture.md +0 -114
- package/templates/apps/web/__tests__/lighthouse-fixtures/regression-bloat.tsx +0 -94
- package/templates/apps/web/__tests__/page.test.tsx +0 -230
- package/templates/apps/web/__tests__/products-services-phase1.test.tsx +0 -321
- package/templates/apps/web/__tests__/projects-routes.test.tsx +0 -392
- package/templates/apps/web/__tests__/resources-downloads-integrations-phase2.test.tsx +0 -475
- package/templates/apps/web/__tests__/routes/ai-plugin-json.test.ts +0 -53
- package/templates/apps/web/__tests__/routes/blog-route.test.tsx +0 -191
- package/templates/apps/web/__tests__/routes/llms-txt.test.ts +0 -123
- package/templates/apps/web/__tests__/routes/robots-txt.test.ts +0 -52
- package/templates/apps/web/__tests__/routes/sitemap-xml.test.ts +0 -78
- package/templates/apps/web/__tests__/routes/well-known-mcp.test.ts +0 -203
- package/templates/apps/web/__tests__/section-routes.test.tsx +0 -693
- package/templates/apps/web/__tests__/security-headers.test.ts +0 -201
- package/templates/apps/web/__tests__/sentry-config.test.ts +0 -116
- package/templates/apps/web/__tests__/sitemap-locales.test.ts +0 -415
- package/templates/apps/web/__tests__/standalone-informational-routes.test.tsx +0 -844
- package/templates/apps/web/app/[locale]/__tests__/layout.test.tsx +0 -120
- package/templates/apps/web/app/[locale]/__tests__/page-renderer-migration.test.tsx +0 -202
- package/templates/apps/web/app/__tests__/error-boundaries.test.tsx +0 -152
- package/templates/apps/web/content/en/blog/index.mdx +0 -15
- package/templates/apps/web/content/en/blog/launching-ailk.mdx +0 -20
- package/templates/apps/web/content/en/docs/getting-started.mdx +0 -30
- package/templates/content/.gitkeep +0 -0
- package/templates/content/CLAUDE.md +0 -85
- package/templates/content/ar/HomePage/home.mdx +0 -123
- package/templates/content/ar/_site.mdx +0 -35
- package/templates/content/de/_site.mdx +0 -29
- package/templates/content/en/.gitkeep +0 -0
- package/templates/content/en/AboutPage/about.schema.json +0 -82
- package/templates/content/en/BookCall/book-call.mdx +0 -19
- package/templates/content/en/BookCall/book-call.schema.json +0 -121
- package/templates/content/en/BreadcrumbList/site-breadcrumbs.mdx +0 -20
- package/templates/content/en/CareersList/careers.mdx +0 -24
- package/templates/content/en/CaseStudiesList/case-studies.mdx +0 -22
- package/templates/content/en/CaseStudiesList/showcase.mdx +0 -8
- package/templates/content/en/CaseStudy/ailk-aeo-rollout.mdx +0 -32
- package/templates/content/en/CaseStudy/schema-builder-migration.mdx +0 -41
- package/templates/content/en/ClientsList/clients.mdx +0 -38
- package/templates/content/en/ContactPage/contact.schema.json +0 -100
- package/templates/content/en/ContactPoint/primary.mdx +0 -14
- package/templates/content/en/Course/aeo-fundamentals.mdx +0 -37
- package/templates/content/en/Course/schema-builder-workshop.mdx +0 -62
- package/templates/content/en/CoursesList/courses.mdx +0 -22
- package/templates/content/en/DownloadsList/downloads.mdx +0 -22
- package/templates/content/en/Event/aeo-office-hours-may-2026.mdx +0 -39
- package/templates/content/en/Event/ailk-launch-day-2026.mdx +0 -35
- package/templates/content/en/EventsList/events.mdx +0 -26
- package/templates/content/en/FAQPage/faq.schema.json +0 -231
- package/templates/content/en/GlossaryList/glossary.mdx +0 -19
- package/templates/content/en/GlossaryList/glossary.schema.json +0 -300
- package/templates/content/en/HomePage/home.schema.json +0 -88
- package/templates/content/en/HomePage/launch-service.mdx +0 -475
- package/templates/content/en/HomePage/launch-service.schema.json +0 -138
- package/templates/content/en/HowTo/add-mdx-content.mdx +0 -38
- package/templates/content/en/HowTo/configure-aeo-scoring.mdx +0 -35
- package/templates/content/en/HowTo/set-up-json-ld.mdx +0 -36
- package/templates/content/en/Integration/github-actions.mdx +0 -31
- package/templates/content/en/Integration/openai-api.mdx +0 -26
- package/templates/content/en/Integration/vercel.mdx +0 -32
- package/templates/content/en/IntegrationsList/integrations.mdx +0 -22
- package/templates/content/en/JobPosting/content-strategist.mdx +0 -46
- package/templates/content/en/JobPosting/developer-advocate.mdx +0 -47
- package/templates/content/en/JobPosting/senior-fullstack-engineer.mdx +0 -57
- package/templates/content/en/LandingPage/aeo-audit.mdx +0 -19
- package/templates/content/en/LandingPage/aeo-audit.schema.json +0 -92
- package/templates/content/en/LandingPage/agency-waitlist.mdx +0 -340
- package/templates/content/en/LandingPage/agency-waitlist.schema.json +0 -152
- package/templates/content/en/LandingPage/developers.mdx +0 -19
- package/templates/content/en/LandingPage/developers.schema.json +0 -109
- package/templates/content/en/LandingPage/for-agencies.mdx +0 -390
- package/templates/content/en/LandingPage/for-agencies.schema.json +0 -107
- package/templates/content/en/LandingPage/for-ai-search-agencies.mdx +0 -417
- package/templates/content/en/LandingPage/for-ai-search-agencies.schema.json +0 -105
- package/templates/content/en/LandingPage/for-businesses.mdx +0 -19
- package/templates/content/en/LandingPage/for-businesses.schema.json +0 -96
- package/templates/content/en/LandingPage/platform.mdx +0 -369
- package/templates/content/en/LandingPage/platform.schema.json +0 -166
- package/templates/content/en/LandingPage/vs-ai-website-builders.mdx +0 -19
- package/templates/content/en/LandingPage/vs-ai-website-builders.schema.json +0 -102
- package/templates/content/en/LandingPage/vs-wordpress.mdx +0 -19
- package/templates/content/en/LandingPage/vs-wordpress.schema.json +0 -53
- package/templates/content/en/NewsletterSignup/newsletter.mdx +0 -236
- package/templates/content/en/NewsletterSignup/newsletter.schema.json +0 -91
- package/templates/content/en/Organization/site-owner.mdx +0 -16
- package/templates/content/en/Partner/prisma.mdx +0 -31
- package/templates/content/en/Partner/supabase.mdx +0 -30
- package/templates/content/en/Partner/vercel.mdx +0 -28
- package/templates/content/en/PartnersList/partners.mdx +0 -22
- package/templates/content/en/Person/founder.mdx +0 -18
- package/templates/content/en/Pricing/pricing.mdx +0 -538
- package/templates/content/en/Pricing/pricing.schema.json +0 -209
- package/templates/content/en/PrivacyPage/privacy.mdx +0 -165
- package/templates/content/en/PrivacyPage/privacy.schema.json +0 -64
- package/templates/content/en/Product/ailk.mdx +0 -19
- package/templates/content/en/Product/ailk.schema.json +0 -94
- package/templates/content/en/Product/api-launch-kit.mdx +0 -26
- package/templates/content/en/Product/launch-kit.mdx +0 -28
- package/templates/content/en/ProductsList/products.mdx +0 -26
- package/templates/content/en/Project/ailk-phase-2.mdx +0 -54
- package/templates/content/en/ProjectsList/projects.mdx +0 -25
- package/templates/content/en/ResourceDetail/aeo-scoring-guide.mdx +0 -27
- package/templates/content/en/ResourceDetail/content-model-whitepaper.mdx +0 -28
- package/templates/content/en/ResourceDetail/schema-org-quick-reference.mdx +0 -39
- package/templates/content/en/ResourcesHub/resources.mdx +0 -22
- package/templates/content/en/Service/consulting.mdx +0 -22
- package/templates/content/en/Service/implementation.mdx +0 -30
- package/templates/content/en/Service/launch-service.mdx +0 -232
- package/templates/content/en/Service/launch.mdx +0 -228
- package/templates/content/en/ServicesList/services.mdx +0 -84
- package/templates/content/en/SoftwareProduct/ailk-aeo.mdx +0 -44
- package/templates/content/en/SoftwareProduct/ailk-content-adapters.mdx +0 -47
- package/templates/content/en/SoftwareProduct/ailk-schema.mdx +0 -49
- package/templates/content/en/TeamList/team.mdx +0 -63
- package/templates/content/en/TeamMember/founder.mdx +0 -72
- package/templates/content/en/TermsPage/terms.mdx +0 -19
- package/templates/content/en/TermsPage/terms.schema.json +0 -99
- package/templates/content/en/TestimonialsList/testimonials.mdx +0 -44
- package/templates/content/en/UseCasesList/use-cases.mdx +0 -30
- package/templates/content/en/Whitepaper/ax-first-product-development.mdx +0 -27
- package/templates/content/en/Whitepaper/open-core-commercial-strategy.mdx +0 -27
- package/templates/content/en/Whitepaper/structured-data-for-answer-engines.mdx +0 -31
- package/templates/content/en-XA/AboutPage/about.mdx +0 -8
- package/templates/content/en-XA/FAQPage/faq.mdx +0 -8
- package/templates/content/en-XA/HomePage/home.mdx +0 -8
- package/templates/content/es/_site.mdx +0 -30
- package/templates/content/fr/_site.mdx +0 -30
- package/templates/content/ja/_site.mdx +0 -30
- package/templates/content/pt-BR/AboutPage/about.mdx +0 -8
- package/templates/content/pt-BR/FAQPage/faq.mdx +0 -8
- package/templates/content/pt-BR/HomePage/home.mdx +0 -23
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Instant AEO score service (#4666 D3/D4).
|
|
3
|
+
*
|
|
4
|
+
* Productizes the existing pure `@working-theory/aeo` scorer behind the
|
|
5
|
+
* SSRF-guarded external-URL crawler at `@working-theory/aeo/fetch`. The
|
|
6
|
+
* rule set, severity table, and `identityMode`/`locale` defaults are
|
|
7
|
+
* declared ONCE, here.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
runAudit,
|
|
11
|
+
schemaCoverage,
|
|
12
|
+
jsonldValidity,
|
|
13
|
+
entityMarkup,
|
|
14
|
+
faqHowtoIncentive,
|
|
15
|
+
EXAMPLE_SCORE_CRAWL,
|
|
16
|
+
EXAMPLE_SCORE_META,
|
|
17
|
+
EXAMPLE_SCORE_ANNOTATIONS,
|
|
18
|
+
type Rule,
|
|
19
|
+
type AuditReport,
|
|
20
|
+
type PerRuleResult,
|
|
21
|
+
} from "@working-theory/aeo";
|
|
22
|
+
import {
|
|
23
|
+
assertUrlSafe,
|
|
24
|
+
crawlSite,
|
|
25
|
+
buildAuditPayload,
|
|
26
|
+
type CrawlResult,
|
|
27
|
+
type FetchImpl,
|
|
28
|
+
type Resolver,
|
|
29
|
+
type SkippedEntry,
|
|
30
|
+
} from "@working-theory/aeo/fetch";
|
|
31
|
+
import type {
|
|
32
|
+
AeoDimension,
|
|
33
|
+
AeoDimensionId,
|
|
34
|
+
AeoScoreResult,
|
|
35
|
+
} from "@working-theory/validation";
|
|
36
|
+
|
|
37
|
+
/** The service's fixed rule set — the four content rules only (§ 2.1 FR4). */
|
|
38
|
+
export const AEO_SCORE_RULES: Rule[] = [
|
|
39
|
+
schemaCoverage,
|
|
40
|
+
jsonldValidity,
|
|
41
|
+
entityMarkup,
|
|
42
|
+
faqHowtoIncentive,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/** Severity table, verbatim from prospect-audit-site's "Compose the score-led sales report". */
|
|
46
|
+
export const AEO_DIMENSION_SEVERITY: Record<AeoDimensionId, "high" | "medium" | "low"> = {
|
|
47
|
+
"schema-coverage": "high",
|
|
48
|
+
"entity-markup": "high",
|
|
49
|
+
"jsonld-validity": "medium",
|
|
50
|
+
"faq-howto-incentive": "low",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const AEO_DIMENSION_LABELS: Record<AeoDimensionId, string> = {
|
|
54
|
+
"schema-coverage": "Schema Coverage",
|
|
55
|
+
"jsonld-validity": "JSON-LD Validity",
|
|
56
|
+
"entity-markup": "Entity Markup",
|
|
57
|
+
"faq-howto-incentive": "FAQ / HowTo Incentive",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** D5 — the route's default + the configured-budget cap, both declared once. */
|
|
61
|
+
export const AEO_SCORE_DEFAULT_BUDGET_MS = 6000;
|
|
62
|
+
export const AEO_SCORE_MAX_BUDGET_MS = 15000;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve `AEO_SCORE_BUDGET_MS` for the route. A missing/unparseable/
|
|
66
|
+
* non-positive value falls back to the default silently (nothing to warn
|
|
67
|
+
* about — the env var is simply unset in dev). A value above the 15 000 ms
|
|
68
|
+
* cap is logged and falls back to the default (D5) — raising it beyond the
|
|
69
|
+
* crawler's own 15 s per-fetch bound would collide with the handler's 25 s
|
|
70
|
+
* request guard once cold start is added. Re-checked on EVERY request, not
|
|
71
|
+
* once at process startup — a serverless deployment has no persistent
|
|
72
|
+
* startup phase to check this at.
|
|
73
|
+
*/
|
|
74
|
+
export function resolveScoreBudgetMs(
|
|
75
|
+
rawEnv: string | undefined,
|
|
76
|
+
log?: { warn: (msg: string) => void },
|
|
77
|
+
): number {
|
|
78
|
+
if (!rawEnv) return AEO_SCORE_DEFAULT_BUDGET_MS;
|
|
79
|
+
const parsed = Number(rawEnv);
|
|
80
|
+
if (!Number.isFinite(parsed) || parsed <= 0) return AEO_SCORE_DEFAULT_BUDGET_MS;
|
|
81
|
+
if (parsed > AEO_SCORE_MAX_BUDGET_MS) {
|
|
82
|
+
log?.warn(
|
|
83
|
+
`AEO_SCORE_BUDGET_MS=${rawEnv} exceeds the ${AEO_SCORE_MAX_BUDGET_MS}ms cap; falling back to the ${AEO_SCORE_DEFAULT_BUDGET_MS}ms default.`,
|
|
84
|
+
);
|
|
85
|
+
return AEO_SCORE_DEFAULT_BUDGET_MS;
|
|
86
|
+
}
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const SEVERITY_RANK: Record<"high" | "medium" | "low", number> = {
|
|
91
|
+
high: 0,
|
|
92
|
+
medium: 1,
|
|
93
|
+
low: 2,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** `lowestTwo` ordering (D4): score asc, then severity high>medium>low, then weight desc. */
|
|
97
|
+
function compareDimensions(a: AeoDimension, b: AeoDimension): number {
|
|
98
|
+
if (a.score !== b.score) return a.score - b.score;
|
|
99
|
+
const severityDiff = SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity];
|
|
100
|
+
if (severityDiff !== 0) return severityDiff;
|
|
101
|
+
return b.weight - a.weight;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const FAQ_HOWTO_INCENTIVE_ID: AeoDimensionId = "faq-howto-incentive";
|
|
105
|
+
|
|
106
|
+
/** The fixed opportunity sentence for the incentive dimension (D4) — its per-page results carry no evidence. */
|
|
107
|
+
function faqHowtoOpportunityFinding(slug: string): string {
|
|
108
|
+
return `${slug}: no FAQPage or HowTo structured data (a visibility opportunity, not a defect)`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildDimension(ruleId: AeoDimensionId, report: AuditReport): AeoDimension {
|
|
112
|
+
const perRule = report.perRule.find((r) => r.id === ruleId) as PerRuleResult;
|
|
113
|
+
const perPage = perRule.perPage ?? [];
|
|
114
|
+
const fraction = perRule.score ?? (perRule.pass ? 1 : 0);
|
|
115
|
+
const score = Math.round(fraction * 100);
|
|
116
|
+
|
|
117
|
+
let finding: string | null;
|
|
118
|
+
let pagesPassing: number;
|
|
119
|
+
const pagesTotal = perPage.length > 0 ? perPage.length : 1;
|
|
120
|
+
|
|
121
|
+
if (ruleId === FAQ_HOWTO_INCENTIVE_ID) {
|
|
122
|
+
pagesPassing = perPage.filter((p) => p.bonus === true).length;
|
|
123
|
+
const firstMissing = perPage.find((p) => p.bonus !== true);
|
|
124
|
+
finding = firstMissing ? faqHowtoOpportunityFinding(firstMissing.slug) : null;
|
|
125
|
+
} else {
|
|
126
|
+
pagesPassing = perPage.length > 0 ? perPage.filter((p) => p.pass).length : (perRule.pass ? 1 : 0);
|
|
127
|
+
const firstFailing = perPage.find((p) => !p.pass);
|
|
128
|
+
finding = firstFailing ? (firstFailing.evidence ?? perRule.evidence ?? null) : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
id: ruleId,
|
|
133
|
+
label: AEO_DIMENSION_LABELS[ruleId],
|
|
134
|
+
weight: perRule.weight,
|
|
135
|
+
score,
|
|
136
|
+
severity: AEO_DIMENSION_SEVERITY[ruleId],
|
|
137
|
+
finding,
|
|
138
|
+
pagesPassing,
|
|
139
|
+
pagesTotal,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* `assertUrlSafe`'s per-path rejection text (`packages/aeo/src/fetch/ssrf.ts`)
|
|
145
|
+
* can disclose which internal host/IP a crawl target resolved to — an
|
|
146
|
+
* anonymous-endpoint recon primitive (AILK #5110, mirroring the #4707 fix
|
|
147
|
+
* already applied to `scoreUrl`'s top-level `unavailable`/`ssrf_blocked`
|
|
148
|
+
* check below). `crawlSite` re-checks every path individually (DNS-rebinding
|
|
149
|
+
* defense) and writes the raw `assertUrlSafe` text straight into
|
|
150
|
+
* `SkippedEntry.reason` (`packages/aeo/src/fetch/crawl.ts:125`) — deliberately
|
|
151
|
+
* so, per that file's own test
|
|
152
|
+
* (`packages/aeo/__tests__/fetch/crawl.test.ts:47-49`), so the redaction
|
|
153
|
+
* happens HERE instead, at the boundary where a `ready` result's
|
|
154
|
+
* `meta.skipped` is assembled. Every `assertUrlSafe` rejection message has
|
|
155
|
+
* one of these six fixed prefixes; `crawlSite`'s other `SkippedEntry.reason`
|
|
156
|
+
* producers (`"budget_exceeded"`, a `fetchPage` HTTP/redirect/fetch-failure
|
|
157
|
+
* message) never do.
|
|
158
|
+
*/
|
|
159
|
+
const SSRF_GUARD_REASON_PATTERN =
|
|
160
|
+
/^(Not a valid URL:|Only http\(s\) URLs allowed,|Blocked host \(internal\/loopback\/metadata\):|DNS resolution failed for|No DNS addresses resolved for|Host .+ resolves to a blocked address:)/;
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* `meta.skipped[]` boundary serializer (#5110). A per-path SSRF-guard
|
|
164
|
+
* rejection is logged server-side (the specific host/IP detail, for operator
|
|
165
|
+
* diagnosis) and replaced on the wire with the same generic `"ssrf_blocked"`
|
|
166
|
+
* sentinel `scoreUrl`'s top-level check already returns (below) — never the
|
|
167
|
+
* raw text. Any other skip reason passes through unchanged.
|
|
168
|
+
*/
|
|
169
|
+
function toClientSkippedEntry(entry: SkippedEntry): SkippedEntry {
|
|
170
|
+
if (!SSRF_GUARD_REASON_PATTERN.test(entry.reason)) return entry;
|
|
171
|
+
console.warn("aeo-score: per-path SSRF-guard rejection (redacted on the wire)", {
|
|
172
|
+
path: entry.path,
|
|
173
|
+
url: entry.url,
|
|
174
|
+
reason: entry.reason,
|
|
175
|
+
});
|
|
176
|
+
return { ...entry, reason: "ssrf_blocked" };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Pure: crawl payload → runAudit → dimensions (D3/D4). Never throws on an empty crawl — the caller checks first. */
|
|
180
|
+
export function scoreCrawl(
|
|
181
|
+
crawl: CrawlResult,
|
|
182
|
+
opts?: { now?: () => Date },
|
|
183
|
+
): AeoScoreResult {
|
|
184
|
+
if (crawl.pages.length === 0) {
|
|
185
|
+
return {
|
|
186
|
+
status: "unavailable",
|
|
187
|
+
reason: "no_pages_fetched",
|
|
188
|
+
message: "No pages were fetched.",
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const payload = buildAuditPayload(crawl.pages);
|
|
193
|
+
const report = runAudit(
|
|
194
|
+
{ ...payload, locale: "en", identityMode: "organization" },
|
|
195
|
+
AEO_SCORE_RULES,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
const dimensions = AEO_SCORE_RULES.map((rule) =>
|
|
199
|
+
buildDimension(rule.id as AeoDimensionId, report),
|
|
200
|
+
);
|
|
201
|
+
const lowestTwo = [...dimensions].sort(compareDimensions).slice(0, 2);
|
|
202
|
+
const partial = crawl.skipped.some((s) => s.reason === "budget_exceeded");
|
|
203
|
+
const now = opts?.now ? opts.now() : new Date();
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
status: "ready",
|
|
207
|
+
score: Math.round(report.fractionalScore ?? report.score),
|
|
208
|
+
dimensions,
|
|
209
|
+
lowestTwo,
|
|
210
|
+
meta: {
|
|
211
|
+
auditedAt: now.toISOString(),
|
|
212
|
+
pagesAudited: crawl.pages.length,
|
|
213
|
+
partial,
|
|
214
|
+
skipped: crawl.skipped.map(toClientSkippedEntry),
|
|
215
|
+
schemaVersion: report.meta.schemaVersion,
|
|
216
|
+
registryVersion: report.meta.registryVersion,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface ScoreUrlDeps {
|
|
222
|
+
fetchImpl?: FetchImpl;
|
|
223
|
+
resolver?: Resolver;
|
|
224
|
+
budgetMs?: number;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** `assertUrlSafe` → `crawlSite` → `scoreCrawl` (D3). */
|
|
228
|
+
export async function scoreUrl(
|
|
229
|
+
url: string,
|
|
230
|
+
deps: ScoreUrlDeps = {},
|
|
231
|
+
): Promise<AeoScoreResult> {
|
|
232
|
+
const safe = await assertUrlSafe(url, {
|
|
233
|
+
...(deps.resolver !== undefined && { resolver: deps.resolver }),
|
|
234
|
+
});
|
|
235
|
+
if (!safe.ok) {
|
|
236
|
+
return { status: "unavailable", reason: "ssrf_blocked", message: safe.reason };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const budgetMs = deps.budgetMs ?? AEO_SCORE_DEFAULT_BUDGET_MS;
|
|
240
|
+
try {
|
|
241
|
+
const crawl = await crawlSite(url, {
|
|
242
|
+
...(deps.fetchImpl !== undefined && { fetchImpl: deps.fetchImpl }),
|
|
243
|
+
...(deps.resolver !== undefined && { resolver: deps.resolver }),
|
|
244
|
+
deadline: Date.now() + budgetMs,
|
|
245
|
+
});
|
|
246
|
+
if (crawl.pages.length === 0) {
|
|
247
|
+
return {
|
|
248
|
+
status: "unavailable",
|
|
249
|
+
reason: "no_pages_fetched",
|
|
250
|
+
message: "No pages were fetched within the crawl budget.",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return scoreCrawl(crawl);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
256
|
+
return { status: "unavailable", reason: "crawl_failed", message };
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The bundled pre-launch example — no runtime `fs` read (D6). */
|
|
261
|
+
export function loadExampleScore(): AeoScoreResult {
|
|
262
|
+
const result = scoreCrawl(EXAMPLE_SCORE_CRAWL);
|
|
263
|
+
if (result.status !== "ready") {
|
|
264
|
+
// The fixture is committed and known-good; this should never happen.
|
|
265
|
+
throw new Error("Example score fixture failed to score.");
|
|
266
|
+
}
|
|
267
|
+
const annotate = (d: AeoDimension): AeoDimension => ({
|
|
268
|
+
...d,
|
|
269
|
+
annotation: EXAMPLE_SCORE_ANNOTATIONS[d.id],
|
|
270
|
+
});
|
|
271
|
+
return {
|
|
272
|
+
...result,
|
|
273
|
+
dimensions: result.dimensions.map(annotate),
|
|
274
|
+
lowestTwo: result.lowestTwo.map(annotate),
|
|
275
|
+
meta: {
|
|
276
|
+
...result.meta,
|
|
277
|
+
example: true,
|
|
278
|
+
sourceUrl: EXAMPLE_SCORE_META.sourceUrl,
|
|
279
|
+
generatedAt: EXAMPLE_SCORE_META.generatedAt,
|
|
280
|
+
// Wire-visible disclosure (acceptance-review finding, #4666): whether
|
|
281
|
+
// the crawl data is a genuine fetch of `sourceUrl` or a hand-authored
|
|
282
|
+
// representative dataset. Sourced from EXAMPLE_SCORE_META itself, not
|
|
283
|
+
// hardcoded here, so regenerating the fixture from a real crawl flips
|
|
284
|
+
// this automatically.
|
|
285
|
+
illustrative: EXAMPLE_SCORE_META.illustrative,
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file answer-distributions.ts
|
|
3
|
+
* @description Shared per-question answer bucketing (#4771 D4).
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `waitlist-dashboard.ts`'s `computeMetricRow` (the original
|
|
6
|
+
* inline `distributions` body) — moved verbatim, not rewritten, so
|
|
7
|
+
* `waitlist-dashboard.test.ts` stays green with NO edits to that file (the
|
|
8
|
+
* AC 7 regression floor).
|
|
9
|
+
*
|
|
10
|
+
* Takes already-flattened answer maps, not rows: `waitlist-dashboard.ts`
|
|
11
|
+
* passes `leads.map((l) => l.payload)`; `flow-aggregate.ts` passes
|
|
12
|
+
* `sessions.map((s) => flattenSessionState(s.state))`. That is what lets one
|
|
13
|
+
* implementation serve two different row types without either service
|
|
14
|
+
* knowing about the other's.
|
|
15
|
+
*
|
|
16
|
+
* Hosted as a THIRD, neutral module rather than inside `flow-aggregate.ts`
|
|
17
|
+
* (the issue's own `## Scope` names only that file) — a deliberate scope
|
|
18
|
+
* extension per `rules/worker-contract.md` § Mid-task scope expansion:
|
|
19
|
+
* hosting the shared helper in `flow-aggregate.ts` would make the operator
|
|
20
|
+
* dashboard import from the flow-aggregate service, an import direction no
|
|
21
|
+
* reader would predict from the names.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { logger } from "@working-theory/observability";
|
|
25
|
+
import type { WaitlistAnswerType } from "@working-theory/validation";
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Defensive upper bound on an aggregation read (Medium finding, PR #4687
|
|
29
|
+
* security review). Caps the raw row count any SINGLE scoped read can
|
|
30
|
+
* return, so a `kind:"all"` aggregation spanning many sites (or a flow with
|
|
31
|
+
* many completed sessions) cannot load an unbounded result set into memory.
|
|
32
|
+
* Generous enough that no real deployment should hit it yet measured — if
|
|
33
|
+
* one does, the affected read undercounts rather than growing unbounded,
|
|
34
|
+
* and `logAggregationCapHit` below leaves a signal to act on (measure, then
|
|
35
|
+
* follow up with a real bound or DB-side aggregation).
|
|
36
|
+
*/
|
|
37
|
+
export const MAX_AGGREGATION_ROWS = 20_000;
|
|
38
|
+
|
|
39
|
+
export function logAggregationCapHit(where: string, rowCount: number): void {
|
|
40
|
+
if (rowCount < MAX_AGGREGATION_ROWS) return;
|
|
41
|
+
logger.warn(
|
|
42
|
+
{ where, rowCount, cap: MAX_AGGREGATION_ROWS },
|
|
43
|
+
"[answer-distributions] aggregation read hit its row cap — results may undercount",
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A question's declared option set + the two fields the bucketer needs. */
|
|
48
|
+
export type AnswerDistributionQuestion = {
|
|
49
|
+
id: string;
|
|
50
|
+
answerType: WaitlistAnswerType;
|
|
51
|
+
// `| undefined` (not just `?`) — the repo's `exactOptionalPropertyTypes`
|
|
52
|
+
// distinguishes "key absent" from "key present with value undefined", and
|
|
53
|
+
// both `WaitlistQuestion.options` and `WaitlistExperimentConfig`'s own
|
|
54
|
+
// `.optional()`-inferred fields are of the latter, wider shape.
|
|
55
|
+
options?: readonly { value: string }[] | undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export type AnswerDistributionOption = {
|
|
59
|
+
value: string;
|
|
60
|
+
count: number;
|
|
61
|
+
share: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type AnswerDistribution = {
|
|
65
|
+
questionId: string;
|
|
66
|
+
answerType: WaitlistAnswerType;
|
|
67
|
+
answered: number;
|
|
68
|
+
options: AnswerDistributionOption[];
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Read one question's answer out of an already-flattened answer map — moved
|
|
73
|
+
* verbatim from `waitlist-dashboard.ts`'s (formerly local) `readPayloadAnswer`.
|
|
74
|
+
*/
|
|
75
|
+
export function readPayloadAnswer(
|
|
76
|
+
payload: unknown,
|
|
77
|
+
questionId: string,
|
|
78
|
+
): string | string[] | undefined {
|
|
79
|
+
if (typeof payload !== "object" || payload === null) return undefined;
|
|
80
|
+
const value = (payload as Record<string, unknown>)[questionId];
|
|
81
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
82
|
+
if (Array.isArray(value)) {
|
|
83
|
+
const strings = value.filter((v): v is string => typeof v === "string");
|
|
84
|
+
return strings.length > 0 ? strings : undefined;
|
|
85
|
+
}
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Bucket every declared question's answers across a set of already-flattened
|
|
91
|
+
* answer maps — moved verbatim from `computeMetricRow`'s inline
|
|
92
|
+
* `distributions` body. A value outside a question's declared options is
|
|
93
|
+
* counted under `"(other)"`; `text` / `email` / `url` questions are never
|
|
94
|
+
* charted (empty `options`, `answered` still populated).
|
|
95
|
+
*/
|
|
96
|
+
export function computeAnswerDistributions(
|
|
97
|
+
questions: readonly AnswerDistributionQuestion[],
|
|
98
|
+
answerSets: readonly unknown[],
|
|
99
|
+
): AnswerDistribution[] {
|
|
100
|
+
return questions.map((q) => {
|
|
101
|
+
const answeredSets = answerSets.filter(
|
|
102
|
+
(payload) => readPayloadAnswer(payload, q.id) !== undefined,
|
|
103
|
+
);
|
|
104
|
+
const answered = answeredSets.length;
|
|
105
|
+
if (
|
|
106
|
+
q.answerType === "text" ||
|
|
107
|
+
q.answerType === "email" ||
|
|
108
|
+
q.answerType === "url"
|
|
109
|
+
) {
|
|
110
|
+
return {
|
|
111
|
+
questionId: q.id,
|
|
112
|
+
answerType: q.answerType,
|
|
113
|
+
answered,
|
|
114
|
+
options: [],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
const declared = new Set((q.options ?? []).map((o) => o.value));
|
|
118
|
+
const counts = new Map<string, number>();
|
|
119
|
+
for (const payload of answeredSets) {
|
|
120
|
+
const value = readPayloadAnswer(payload, q.id);
|
|
121
|
+
const values = Array.isArray(value)
|
|
122
|
+
? value
|
|
123
|
+
: value !== undefined
|
|
124
|
+
? [value]
|
|
125
|
+
: [];
|
|
126
|
+
for (const v of values) {
|
|
127
|
+
const bucket = declared.has(v) ? v : "(other)";
|
|
128
|
+
counts.set(bucket, (counts.get(bucket) ?? 0) + 1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const options = [...counts.entries()].map(([value, count]) => ({
|
|
132
|
+
value,
|
|
133
|
+
count,
|
|
134
|
+
share: answered > 0 ? count / answered : 0,
|
|
135
|
+
}));
|
|
136
|
+
return { questionId: q.id, answerType: q.answerType, answered, options };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file consent.ts
|
|
3
|
+
* @description The consent-grant writer (#5275 Part B) — `recordConsent`,
|
|
4
|
+
* the one function that turns a person's answers to a site's consent
|
|
5
|
+
* purposes into `ConsentGrant` rows, plus `recordLegacyConsent`, the D11
|
|
6
|
+
* path for a caller that could only say `consent: true`.
|
|
7
|
+
*
|
|
8
|
+
* SURFACE-AGNOSTIC BY DESIGN (D10). A grant's subject is a `subjectType` +
|
|
9
|
+
* `subjectId` pair of plain scalars with no relation into any other table
|
|
10
|
+
* (ADR 0009 invariant 1), so a new surface — the newsletter subscribe path
|
|
11
|
+
* is the named follow-up — adopts consent by adding ONE call here and NO
|
|
12
|
+
* migration. Nothing in this module knows what a project listing is.
|
|
13
|
+
*
|
|
14
|
+
* THE EVIDENCE RULE (spec §5.2): no row is ever written with statement text
|
|
15
|
+
* the system did not actually render to that person. Two consequences:
|
|
16
|
+
*
|
|
17
|
+
* - `recordConsent` resolves each statement from the SAME registry and the
|
|
18
|
+
* SAME `resolveConsentStatement` the renderer used (D7), so the recorded
|
|
19
|
+
* wording and the rendered wording are one string by construction, and a
|
|
20
|
+
* request body never carries wording at all. A purpose id the registry
|
|
21
|
+
* does not know is skipped, not written: no statement was rendered for it.
|
|
22
|
+
* - A grant whose wording cannot be proven — a legacy `consent: true` body,
|
|
23
|
+
* or the migration backfill — carries `UNRECOVERABLE_CONSENT_STATEMENT`,
|
|
24
|
+
* never today's default text. Stamping current wording onto a grant
|
|
25
|
+
* nobody read would fabricate evidence (D11).
|
|
26
|
+
*
|
|
27
|
+
* APPEND-ONLY (D9): every write is a `createMany`; this module never
|
|
28
|
+
* updates or upserts, so a later withdrawal sits beside the earlier grant
|
|
29
|
+
* rather than replacing it. Declines are written as `granted: false` (D8).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { createHash } from "node:crypto";
|
|
33
|
+
|
|
34
|
+
import { logger } from "@working-theory/observability";
|
|
35
|
+
import {
|
|
36
|
+
consentPurposeById,
|
|
37
|
+
resolveConsentStatement,
|
|
38
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
39
|
+
type SiteConsentConfig,
|
|
40
|
+
} from "@working-theory/validation";
|
|
41
|
+
|
|
42
|
+
import { prisma } from "../lib/prisma.js";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The one delegate this module writes through — the shape of `prisma` AND of
|
|
46
|
+
* the `tx` client an interactive `$transaction` hands out, so a caller that
|
|
47
|
+
* creates the subject row can write the grant in the SAME transaction (the
|
|
48
|
+
* listing's `start` does): either the row and its evidence both land, or
|
|
49
|
+
* neither does.
|
|
50
|
+
*/
|
|
51
|
+
export interface ConsentWriteClient {
|
|
52
|
+
consentGrant: {
|
|
53
|
+
createMany(args: { data: ConsentGrantRow[] }): Promise<{ count: number }>;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface ConsentGrantRow {
|
|
58
|
+
siteId: string;
|
|
59
|
+
subjectType: string;
|
|
60
|
+
subjectId: string;
|
|
61
|
+
purposeId: string;
|
|
62
|
+
granted: boolean;
|
|
63
|
+
locale: string;
|
|
64
|
+
statement: string;
|
|
65
|
+
statementHash: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The most keys a `consents` map may carry on an anonymous request. Any real
|
|
70
|
+
* registry is a handful of purposes; the cap bounds the per-request work an
|
|
71
|
+
* unbounded map could buy (security review, Low 3).
|
|
72
|
+
*/
|
|
73
|
+
export const MAX_CONSENT_KEYS = 64;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The statement sentinel for a grant whose wording cannot be proven — a row
|
|
77
|
+
* carrying it records THAT a person consented (and when), not WHAT they read.
|
|
78
|
+
* Written on exactly two paths: `recordLegacyConsent` (a body carrying only
|
|
79
|
+
* the legacy `consent: true` literal, D11) and the consent_grants migration's
|
|
80
|
+
* backfill of pre-existing listings, which must carry this exact literal.
|
|
81
|
+
* It is never a substitute for real wording: a caller that rendered a
|
|
82
|
+
* statement records it through `recordConsent`.
|
|
83
|
+
*/
|
|
84
|
+
export const UNRECOVERABLE_CONSENT_STATEMENT =
|
|
85
|
+
"[unrecoverable: no statement was rendered by this system for this grant]";
|
|
86
|
+
|
|
87
|
+
/** BCP-47 `und` — the locale of a statement nobody read. */
|
|
88
|
+
export const UNRECOVERABLE_CONSENT_LOCALE = "und";
|
|
89
|
+
|
|
90
|
+
/** sha256 hex of a statement — the `statementHash` that groups identical wordings. */
|
|
91
|
+
export function consentStatementHash(statement: string): string {
|
|
92
|
+
return createHash("sha256").update(statement).digest("hex");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The site's consent registry.
|
|
97
|
+
*
|
|
98
|
+
* There is no per-site consent config ROW yet (spec §4 deliberately adds
|
|
99
|
+
* none), so every site resolves to `SITE_CONSENT_CONFIG_DEFAULTS`. This
|
|
100
|
+
* function is the single seam a `SiteConsentConfig` row wires into later
|
|
101
|
+
* (the `listingConfigFor` shape: read the blob, `consentConfigFor(blob)`).
|
|
102
|
+
* The RENDERER (`packages/ui`) resolves its statements from the same
|
|
103
|
+
* `SITE_CONSENT_CONFIG_DEFAULTS`, which is what keeps the recorded statement
|
|
104
|
+
* identical to the rendered one until a config row exists — at which point
|
|
105
|
+
* both sides must read the same row.
|
|
106
|
+
*/
|
|
107
|
+
export async function consentConfigForSite(
|
|
108
|
+
_siteId: string,
|
|
109
|
+
): Promise<SiteConsentConfig> {
|
|
110
|
+
return SITE_CONSENT_CONFIG_DEFAULTS;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The id of the first REQUIRED registry purpose `consents` does not GRANT —
|
|
115
|
+
* declined (`false`) or simply absent — or `undefined` when every required
|
|
116
|
+
* purpose is `true`. Absence counts as unmet on purpose (security review,
|
|
117
|
+
* Medium 1): a body carrying `consents: {}` beside `consent: true` would
|
|
118
|
+
* otherwise create the subject with no grant row at all, which is a worse
|
|
119
|
+
* record than the legacy sentinel. A surface whose subject cannot exist
|
|
120
|
+
* without the required purposes (the listing: the row goes on to publish the
|
|
121
|
+
* founder's address) checks this before creating the subject.
|
|
122
|
+
*/
|
|
123
|
+
export function requiredConsentUnmet(
|
|
124
|
+
config: SiteConsentConfig,
|
|
125
|
+
consents: Record<string, boolean>,
|
|
126
|
+
): string | undefined {
|
|
127
|
+
for (const purpose of config.purposes) {
|
|
128
|
+
if (purpose.required && consents[purpose.id] !== true) return purpose.id;
|
|
129
|
+
}
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface RecordConsentInput {
|
|
134
|
+
siteId: string;
|
|
135
|
+
/** e.g. `project_listing`, `subscriber` — a plain scalar, no relation (D10). */
|
|
136
|
+
subjectType: string;
|
|
137
|
+
subjectId: string;
|
|
138
|
+
/** The locale the statements were RENDERED in; the served locale is what gets recorded. */
|
|
139
|
+
locale: string;
|
|
140
|
+
config: SiteConsentConfig;
|
|
141
|
+
/** purposeId → the person's answer. `false` is a stored decline (D8). */
|
|
142
|
+
consents: Record<string, boolean>;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Write one `ConsentGrant` row per answered purpose in one `createMany`.
|
|
147
|
+
* Returns the number of rows written. Append-only — never update/upsert.
|
|
148
|
+
* `db` defaults to the shared client; pass a `$transaction` client to land
|
|
149
|
+
* the grant atomically with the subject row it belongs to.
|
|
150
|
+
*/
|
|
151
|
+
export async function recordConsent(
|
|
152
|
+
input: RecordConsentInput,
|
|
153
|
+
db: ConsentWriteClient = prisma,
|
|
154
|
+
): Promise<number> {
|
|
155
|
+
const rows: ConsentGrantRow[] = [];
|
|
156
|
+
const unknown: string[] = [];
|
|
157
|
+
|
|
158
|
+
for (const [purposeId, granted] of Object.entries(input.consents)) {
|
|
159
|
+
const purpose = consentPurposeById(input.config, purposeId);
|
|
160
|
+
if (!purpose) {
|
|
161
|
+
// Not in the registry ⇒ no statement was rendered for it ⇒ nothing to
|
|
162
|
+
// record (the evidence rule). Collected and logged ONCE per call below,
|
|
163
|
+
// because a client naming a purpose the site does not declare is a
|
|
164
|
+
// drift someone should see — and a map full of junk keys must not buy
|
|
165
|
+
// a log line each.
|
|
166
|
+
unknown.push(purposeId);
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const { statement, locale } = resolveConsentStatement(purpose, input.locale);
|
|
170
|
+
rows.push({
|
|
171
|
+
siteId: input.siteId,
|
|
172
|
+
subjectType: input.subjectType,
|
|
173
|
+
subjectId: input.subjectId,
|
|
174
|
+
purposeId,
|
|
175
|
+
granted,
|
|
176
|
+
locale,
|
|
177
|
+
statement,
|
|
178
|
+
statementHash: consentStatementHash(statement),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (unknown.length > 0) {
|
|
183
|
+
logger.warn(
|
|
184
|
+
{
|
|
185
|
+
siteId: input.siteId,
|
|
186
|
+
subjectType: input.subjectType,
|
|
187
|
+
unknownCount: unknown.length,
|
|
188
|
+
sample: unknown.slice(0, 5),
|
|
189
|
+
},
|
|
190
|
+
"[consent] skipping unknown purpose ids — not in the site's consent registry, no statement was rendered for them",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (rows.length === 0) return 0;
|
|
195
|
+
const result = await db.consentGrant.createMany({ data: rows });
|
|
196
|
+
return result.count;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export interface RecordLegacyConsentInput {
|
|
200
|
+
siteId: string;
|
|
201
|
+
subjectType: string;
|
|
202
|
+
subjectId: string;
|
|
203
|
+
config: SiteConsentConfig;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The D11 path: a body that carried only the legacy `consent: true` literal.
|
|
208
|
+
* Writes ONE grant against the site's first `required` purpose (falling back
|
|
209
|
+
* to its first purpose), `granted: true`, with the statement marked
|
|
210
|
+
* unrecoverable — the caller affirmed, but this system rendered it no
|
|
211
|
+
* wording to affirm.
|
|
212
|
+
*/
|
|
213
|
+
export async function recordLegacyConsent(
|
|
214
|
+
input: RecordLegacyConsentInput,
|
|
215
|
+
db: ConsentWriteClient = prisma,
|
|
216
|
+
): Promise<number> {
|
|
217
|
+
const purpose =
|
|
218
|
+
input.config.purposes.find((p) => p.required) ?? input.config.purposes[0];
|
|
219
|
+
if (!purpose) return 0;
|
|
220
|
+
|
|
221
|
+
const result = await db.consentGrant.createMany({
|
|
222
|
+
data: [
|
|
223
|
+
{
|
|
224
|
+
siteId: input.siteId,
|
|
225
|
+
subjectType: input.subjectType,
|
|
226
|
+
subjectId: input.subjectId,
|
|
227
|
+
purposeId: purpose.id,
|
|
228
|
+
granted: true,
|
|
229
|
+
locale: UNRECOVERABLE_CONSENT_LOCALE,
|
|
230
|
+
statement: UNRECOVERABLE_CONSENT_STATEMENT,
|
|
231
|
+
statementHash: consentStatementHash(UNRECOVERABLE_CONSENT_STATEMENT),
|
|
232
|
+
},
|
|
233
|
+
],
|
|
234
|
+
});
|
|
235
|
+
return result.count;
|
|
236
|
+
}
|