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,768 @@
|
|
|
1
|
+
// oss: false
|
|
2
|
+
/**
|
|
3
|
+
* @file usage-metering.test.ts
|
|
4
|
+
* @description Unit tests for the usage-metering service (ADR 0009 D3
|
|
5
|
+
* service-layer pattern; spec docs/specs/3705-s1-usage-metering-service-spec.md §5).
|
|
6
|
+
*
|
|
7
|
+
* External dependency boundary mocked:
|
|
8
|
+
* - apps/api/src/lib/prisma — the repo-standard `__mocks__/prisma.ts`
|
|
9
|
+
* manual mock (`jest.mock("../../lib/prisma.js")`), extended with the
|
|
10
|
+
* FEAT-047 P1 model namespaces (usageEvent/usagePeriod/llmModel/
|
|
11
|
+
* llmModelPricing/aiMarkupConfig — #3709/#3710, not yet in the generated
|
|
12
|
+
* Prisma client this package compiles against) plus `$transaction`.
|
|
13
|
+
* `usage-metering.ts` casts `prisma` to a local structural delegate type
|
|
14
|
+
* (`UsageMeteringPrismaDelegates`); this test does the same so the mock's
|
|
15
|
+
* `jest.fn()` methods type-check the same way the service's calls do —
|
|
16
|
+
* this test harness stands in for the not-yet-existing live AI call
|
|
17
|
+
* (AC-UB-02 service-level, per the spec's §1 scope-reality note).
|
|
18
|
+
*
|
|
19
|
+
* `aiMarkupConfig.findFirst` is given a REALISTIC filtering implementation
|
|
20
|
+
* over an in-memory fixture "table" (mirroring lead-promotion.test.ts's
|
|
21
|
+
* `seedLeadTable` pattern) so the hierarchy tests (AC-2) exercise the actual
|
|
22
|
+
* precedence the service resolves, not just a static per-call stub.
|
|
23
|
+
*
|
|
24
|
+
* `$transaction` is likewise given REALISTIC commit/rollback semantics over an
|
|
25
|
+
* in-memory `committedEvents` store (`installAtomicDb`), while the BASE client's
|
|
26
|
+
* `usageEvent.create` models an AUTO-COMMIT write. That asymmetry is what makes
|
|
27
|
+
* the H3 atomicity test load-bearing: a non-transactional implementation (write
|
|
28
|
+
* the event, THEN upsert) commits the event immediately, so a failing upsert
|
|
29
|
+
* strands an orphan row in `committedEvents` and the test fails. Only writes
|
|
30
|
+
* routed through `$transaction` roll back.
|
|
31
|
+
*
|
|
32
|
+
* Security-review coverage (PR #3727): every High/Medium finding carries a test
|
|
33
|
+
* that fails without its fix. H1/H2 (per-event `ceil` to whole cents) are
|
|
34
|
+
* deliberately NOT addressed — a spec-level billing-fairness decision escalated
|
|
35
|
+
* to Sam — so the 360¢ expectation below still encodes the current rounding.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
// eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
|
|
39
|
+
jest.mock("../../lib/prisma.js");
|
|
40
|
+
|
|
41
|
+
import type { Id } from "@working-theory/ids";
|
|
42
|
+
|
|
43
|
+
import { prisma } from "../../lib/prisma.js";
|
|
44
|
+
import type { TenantContext } from "../tenant-context.js";
|
|
45
|
+
import {
|
|
46
|
+
calculateProviderCost,
|
|
47
|
+
recordUsageEvent,
|
|
48
|
+
type ModelPricing,
|
|
49
|
+
type RecordUsageEventInput,
|
|
50
|
+
type RecordUsageResult,
|
|
51
|
+
type UsageMeteringPrismaDelegates,
|
|
52
|
+
type UsageWriteClient,
|
|
53
|
+
} from "../usage-metering";
|
|
54
|
+
|
|
55
|
+
// The generated Prisma client doesn't carry the FEAT-047 P1 models yet
|
|
56
|
+
// (#3709/#3710 unmerged) — see the file docblock. Same structural cast the
|
|
57
|
+
// service source uses.
|
|
58
|
+
// eslint-disable-next-line no-restricted-syntax -- prisma's TS type resolves to the real (not-yet-augmented) PrismaClient; the FEAT-047 P1 model delegates (usageEvent/usagePeriod/llmModel/llmModelPricing/aiMarkupConfig, #3709/#3710) don't exist on it yet, so the intermediate `unknown` widening is required, not gratuitous — mirrors the identical cast in usage-metering.ts.
|
|
59
|
+
const db = prisma as unknown as UsageMeteringPrismaDelegates;
|
|
60
|
+
|
|
61
|
+
/** The delegate methods are typed as plain functions; the mock behind them is a jest.Mock. */
|
|
62
|
+
const mockOf = (fn: unknown): jest.Mock => fn as jest.Mock;
|
|
63
|
+
|
|
64
|
+
const ORG_ID = "org_00000000-0000-7000-8000-0000000000a1" as Id<"organization">;
|
|
65
|
+
const WORKSPACE_ID = "wksp_00000000-0000-7000-8000-0000000000b1" as Id<"workspace">;
|
|
66
|
+
const USER_ID = "user_00000000-0000-7000-8000-0000000000c1" as Id<"user">;
|
|
67
|
+
const PROVIDER = "anthropic";
|
|
68
|
+
const MODEL = "claude-fable";
|
|
69
|
+
|
|
70
|
+
/** The resolved tenant grain — what `resolveActiveTenant` hands the service (H5). */
|
|
71
|
+
const TENANT: TenantContext = {
|
|
72
|
+
userId: USER_ID,
|
|
73
|
+
organizationId: ORG_ID,
|
|
74
|
+
workspaceId: WORKSPACE_ID,
|
|
75
|
+
role: "owner",
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
function baseInput(overrides?: Partial<RecordUsageEventInput>): RecordUsageEventInput {
|
|
79
|
+
return {
|
|
80
|
+
tenant: TENANT,
|
|
81
|
+
provider: PROVIDER,
|
|
82
|
+
model: MODEL,
|
|
83
|
+
inputTokensNoCacheWrite: 1_000_000,
|
|
84
|
+
cacheReadTokens: 0,
|
|
85
|
+
outputTokens: 0,
|
|
86
|
+
...overrides,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The in-memory "committed rows" store the atomicity assertions read (H3). */
|
|
91
|
+
let committedEvents: unknown[];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Forges a tenant grain that did NOT come from `resolveActiveTenant` — the H5
|
|
95
|
+
* bypass (a caller casting around the branded `Id<>` types, or a route stuffing
|
|
96
|
+
* request-supplied strings into the grain). The double assertion is the point of
|
|
97
|
+
* the test, not an accident: it reproduces exactly the shape the service's
|
|
98
|
+
* runtime id-grammar check must reject. "Fixing the type at the boundary" would
|
|
99
|
+
* delete the test.
|
|
100
|
+
*/
|
|
101
|
+
function forgedTenant(overrides: Record<string, string>): TenantContext {
|
|
102
|
+
// eslint-disable-next-line no-restricted-syntax -- see the docblock above: the unbranded cast IS the attack under test (H5).
|
|
103
|
+
return { ...TENANT, ...overrides } as unknown as TenantContext;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Installs a DB double with REAL transactional semantics:
|
|
108
|
+
* - `$transaction(fn)` stages writes and commits them only if `fn` resolves;
|
|
109
|
+
* a throw discards the staged rows (ROLLBACK).
|
|
110
|
+
* - the BASE client's `usageEvent.create` commits IMMEDIATELY (auto-commit) —
|
|
111
|
+
* modelling what a non-transactional implementation would do.
|
|
112
|
+
* Both base delegates keep recording their calls, so `toHaveBeenCalled*`
|
|
113
|
+
* assertions work regardless of which path a write took.
|
|
114
|
+
*/
|
|
115
|
+
function installAtomicDb(options?: { upsertError?: Error }) {
|
|
116
|
+
mockOf(db.usageEvent.create).mockImplementation(
|
|
117
|
+
async (args: { data: Record<string, unknown> }) => {
|
|
118
|
+
committedEvents.push(args.data); // auto-commit — nothing can roll this back
|
|
119
|
+
return { id: "evt_1" };
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
mockOf(db.usagePeriod.upsert).mockImplementation(async () => {
|
|
123
|
+
if (options?.upsertError) {
|
|
124
|
+
throw options.upsertError;
|
|
125
|
+
}
|
|
126
|
+
return {};
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
mockOf(db.$transaction).mockImplementation(
|
|
130
|
+
async (fn: (tx: UsageWriteClient) => Promise<unknown>) => {
|
|
131
|
+
const staged: unknown[] = [];
|
|
132
|
+
const tx: UsageWriteClient = {
|
|
133
|
+
usageEvent: {
|
|
134
|
+
create: async (args) => {
|
|
135
|
+
// Record the call on the base mock (so call assertions still work),
|
|
136
|
+
// but STAGE the row — it is only committed if the callback resolves.
|
|
137
|
+
mockOf(db.usageEvent.create).mock.calls.push([args]);
|
|
138
|
+
staged.push(args.data);
|
|
139
|
+
return { id: "evt_1" };
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
usagePeriod: {
|
|
143
|
+
upsert: async (args) =>
|
|
144
|
+
mockOf(db.usagePeriod.upsert)(args) as Promise<unknown>,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
const result = await fn(tx);
|
|
148
|
+
committedEvents.push(...staged); // COMMIT (a throw above skips this → ROLLBACK)
|
|
149
|
+
return result;
|
|
150
|
+
},
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Seeds aiMarkupConfig.findFirst with a realistic hierarchy-precedence filter. */
|
|
155
|
+
function seedMarkupTable(
|
|
156
|
+
rows: Array<{
|
|
157
|
+
provider: string | null;
|
|
158
|
+
model: string | null;
|
|
159
|
+
markupPercent: unknown;
|
|
160
|
+
}>,
|
|
161
|
+
) {
|
|
162
|
+
mockOf(db.aiMarkupConfig.findFirst).mockImplementation(
|
|
163
|
+
async (args: {
|
|
164
|
+
where: { organizationId: string; provider: string | null; model: string | null };
|
|
165
|
+
}) => {
|
|
166
|
+
const { organizationId, provider, model } = args.where;
|
|
167
|
+
if (organizationId !== ORG_ID) return null;
|
|
168
|
+
const row = rows.find((r) => r.provider === provider && r.model === model);
|
|
169
|
+
return row ? { markupPercent: row.markupPercent } : null;
|
|
170
|
+
},
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function seedModel(overrides?: Record<string, unknown>) {
|
|
175
|
+
mockOf(db.llmModel.findFirst).mockResolvedValue({
|
|
176
|
+
tier: "BASE",
|
|
177
|
+
isAutoModel: false,
|
|
178
|
+
chargeExtendedContextAt2x: false,
|
|
179
|
+
consumptionMultiplier: 1,
|
|
180
|
+
...overrides,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function seedPricing(overrides?: Record<string, unknown>) {
|
|
185
|
+
mockOf(db.llmModelPricing.findFirst).mockResolvedValue({
|
|
186
|
+
inputPricePerMillion: 1,
|
|
187
|
+
outputPricePerMillion: 1,
|
|
188
|
+
cachedInputPricePerMillion: null,
|
|
189
|
+
...overrides,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The persisted `UsageEvent` data of the Nth create call. */
|
|
194
|
+
function createdEventData(call = 0): Record<string, unknown> {
|
|
195
|
+
return mockOf(db.usageEvent.create).mock.calls[call]?.[0]?.data as Record<
|
|
196
|
+
string,
|
|
197
|
+
unknown
|
|
198
|
+
>;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The `usagePeriod.upsert` args of the Nth upsert call. */
|
|
202
|
+
function upsertArgs(call = 0): {
|
|
203
|
+
create: Record<string, unknown>;
|
|
204
|
+
update: Record<string, unknown>;
|
|
205
|
+
} {
|
|
206
|
+
return mockOf(db.usagePeriod.upsert).mock.calls[call]?.[0] as {
|
|
207
|
+
create: Record<string, unknown>;
|
|
208
|
+
update: Record<string, unknown>;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function expectInvalidInput(result: RecordUsageResult) {
|
|
213
|
+
expect(result.ok).toBe(false);
|
|
214
|
+
const err = result as Extract<RecordUsageResult, { ok: false }>;
|
|
215
|
+
expect(err.error.kind).toBe("invalid_input");
|
|
216
|
+
expect(err.error.persisted).toBe(false);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
describe("calculateProviderCost", () => {
|
|
220
|
+
// ── AC-1: standard path ────────────────────────────────────────────────────
|
|
221
|
+
it("standard path — no context window / at-or-below 200K", () => {
|
|
222
|
+
const pricing: ModelPricing = {
|
|
223
|
+
inputPricePerMillion: 3,
|
|
224
|
+
outputPricePerMillion: 15,
|
|
225
|
+
cachedInputPricePerMillion: 0.3,
|
|
226
|
+
};
|
|
227
|
+
const cost = calculateProviderCost(1_000_000, 500_000, 200_000, pricing);
|
|
228
|
+
// inputCost=1*3=3, cachedInputCost=0.5*0.3=0.15, outputCost=0.2*15=3
|
|
229
|
+
expect(cost).toBeCloseTo(6.15, 6);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("cached input defaults to half the input rate when unset", () => {
|
|
233
|
+
const pricing: ModelPricing = { inputPricePerMillion: 4, outputPricePerMillion: 20 };
|
|
234
|
+
const cost = calculateProviderCost(1_000_000, 1_000_000, 0, pricing);
|
|
235
|
+
// inputCost=1*4=4, cachedInputCost=1*(4*0.5)=2
|
|
236
|
+
expect(cost).toBeCloseTo(6, 6);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ── AC-1: extended-context 2x split ────────────────────────────────────────
|
|
240
|
+
it("extended-context path — only when chargeExtendedAt2x AND contextWindowSize>200_000", () => {
|
|
241
|
+
const pricing: ModelPricing = {
|
|
242
|
+
inputPricePerMillion: 1,
|
|
243
|
+
outputPricePerMillion: 1,
|
|
244
|
+
cachedInputPricePerMillion: 1,
|
|
245
|
+
};
|
|
246
|
+
// 800K context: standardRatio=200K/800K=0.25, extendedRatio=0.75
|
|
247
|
+
// factor = 0.25*1 + 0.75*2 = 1.75
|
|
248
|
+
const cost = calculateProviderCost(1_000_000, 0, 0, pricing, 800_000, true);
|
|
249
|
+
expect(cost).toBeCloseTo(1.75, 6);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("chargeExtendedAt2x=false skips the surcharge regardless of contextWindowSize", () => {
|
|
253
|
+
const pricing: ModelPricing = { inputPricePerMillion: 1, outputPricePerMillion: 1 };
|
|
254
|
+
const cost = calculateProviderCost(1_000_000, 0, 0, pricing, 800_000, false);
|
|
255
|
+
expect(cost).toBeCloseTo(1, 6);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it("contextWindowSize <= 200_000 skips the surcharge even when chargeExtendedAt2x=true", () => {
|
|
259
|
+
const pricing: ModelPricing = { inputPricePerMillion: 1, outputPricePerMillion: 1 };
|
|
260
|
+
const cost = calculateProviderCost(1_000_000, 0, 0, pricing, 200_000, true);
|
|
261
|
+
expect(cost).toBeCloseTo(1, 6);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("contextWindowSize omitted defaults to 0 — extended pricing skipped (don't guess)", () => {
|
|
265
|
+
const pricing: ModelPricing = { inputPricePerMillion: 1, outputPricePerMillion: 1 };
|
|
266
|
+
const cost = calculateProviderCost(1_000_000, 0, 0, pricing, undefined, true);
|
|
267
|
+
expect(cost).toBeCloseTo(1, 6);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("recordUsageEvent", () => {
|
|
272
|
+
beforeEach(() => {
|
|
273
|
+
jest.clearAllMocks();
|
|
274
|
+
jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
275
|
+
jest.spyOn(console, "error").mockImplementation(() => undefined);
|
|
276
|
+
committedEvents = [];
|
|
277
|
+
installAtomicDb();
|
|
278
|
+
seedModel();
|
|
279
|
+
seedPricing();
|
|
280
|
+
seedMarkupTable([]);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
afterEach(() => {
|
|
284
|
+
jest.restoreAllMocks();
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── AC-3 (AC-UB-02 service-level): write UsageEvent then upsert UsagePeriod ─
|
|
288
|
+
it("AC-3: writes the UsageEvent (grain + costs + attribution=0) then upserts the UsagePeriod", async () => {
|
|
289
|
+
seedModel({ tier: "PREMIUM" });
|
|
290
|
+
seedPricing({ inputPricePerMillion: 3, outputPricePerMillion: 15 });
|
|
291
|
+
|
|
292
|
+
const result = await recordUsageEvent(baseInput());
|
|
293
|
+
|
|
294
|
+
// providerCost = 1M/1e6 * 3 = 3; billedCost = 3 * 1.2 = 3.6
|
|
295
|
+
expect(result.ok).toBe(true);
|
|
296
|
+
const okResult = result as Extract<RecordUsageResult, { ok: true }>;
|
|
297
|
+
expect(okResult.eventId).toBe("evt_1");
|
|
298
|
+
expect(okResult.billedCostUsd).toBeCloseTo(3.6, 6);
|
|
299
|
+
|
|
300
|
+
expect(createdEventData()).toEqual(
|
|
301
|
+
expect.objectContaining({
|
|
302
|
+
organizationId: ORG_ID,
|
|
303
|
+
workspaceId: WORKSPACE_ID,
|
|
304
|
+
userId: USER_ID,
|
|
305
|
+
provider: PROVIDER,
|
|
306
|
+
model: MODEL,
|
|
307
|
+
providerCostUsd: 3,
|
|
308
|
+
markupPercent: 20,
|
|
309
|
+
billedCostUsd: expect.closeTo(3.6, 6),
|
|
310
|
+
modelTier: "PREMIUM",
|
|
311
|
+
wasAutoModel: false,
|
|
312
|
+
includedPortionCents: 0,
|
|
313
|
+
creditBalancePortionCents: 0,
|
|
314
|
+
onDemandPortionCents: 0,
|
|
315
|
+
completionStatus: "success",
|
|
316
|
+
}),
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
// H1/H2 (per-event ceil to whole cents) are DELIBERATELY unchanged — this
|
|
320
|
+
// 360¢ expectation encodes the current, spec-mandated rounding, pending Sam's
|
|
321
|
+
// billing-fairness decision.
|
|
322
|
+
expect(upsertArgs().create).toEqual(
|
|
323
|
+
expect.objectContaining({
|
|
324
|
+
organizationId: ORG_ID,
|
|
325
|
+
tokensConsumed: 1_000_000,
|
|
326
|
+
usageSpendCents: 360, // ceil(3.6 * 100)
|
|
327
|
+
premiumTokensConsumed: 1_000_000,
|
|
328
|
+
}),
|
|
329
|
+
);
|
|
330
|
+
expect(upsertArgs().update).toEqual(
|
|
331
|
+
expect.objectContaining({
|
|
332
|
+
tokensConsumed: { increment: 1_000_000 },
|
|
333
|
+
usageSpendCents: { increment: 360 },
|
|
334
|
+
premiumTokensConsumed: { increment: 1_000_000 },
|
|
335
|
+
}),
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// ── Tier-column selection (BASE / FRONTIER) ────────────────────────────────
|
|
340
|
+
it.each([
|
|
341
|
+
["BASE", "baseTokensConsumed"],
|
|
342
|
+
["FRONTIER", "frontierTokensConsumed"],
|
|
343
|
+
] as const)("upserts the %s tier column (%s)", async (tier, column) => {
|
|
344
|
+
seedModel({ tier });
|
|
345
|
+
|
|
346
|
+
await recordUsageEvent(baseInput());
|
|
347
|
+
|
|
348
|
+
expect(upsertArgs().create).toEqual(
|
|
349
|
+
expect.objectContaining({ [column]: 1_000_000 }),
|
|
350
|
+
);
|
|
351
|
+
expect(upsertArgs().update).toEqual(
|
|
352
|
+
expect.objectContaining({ [column]: { increment: 1_000_000 } }),
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// ── AC-2: markup hierarchy ─────────────────────────────────────────────────
|
|
357
|
+
it("AC-2: markup resolves the model-specific row first", async () => {
|
|
358
|
+
seedMarkupTable([
|
|
359
|
+
{ provider: PROVIDER, model: MODEL, markupPercent: 5 },
|
|
360
|
+
{ provider: PROVIDER, model: null, markupPercent: 10 },
|
|
361
|
+
{ provider: null, model: null, markupPercent: 8 },
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
const result = (await recordUsageEvent(baseInput())) as Extract<
|
|
365
|
+
RecordUsageResult,
|
|
366
|
+
{ ok: true }
|
|
367
|
+
>;
|
|
368
|
+
|
|
369
|
+
expect(result.billedCostUsd).toBeCloseTo(1.05, 6); // providerCost=1 * (1+5/100)
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("AC-2: markup falls back to the provider-wide row", async () => {
|
|
373
|
+
seedMarkupTable([
|
|
374
|
+
{ provider: PROVIDER, model: null, markupPercent: 10 },
|
|
375
|
+
{ provider: null, model: null, markupPercent: 8 },
|
|
376
|
+
]);
|
|
377
|
+
|
|
378
|
+
const result = (await recordUsageEvent(baseInput())) as Extract<
|
|
379
|
+
RecordUsageResult,
|
|
380
|
+
{ ok: true }
|
|
381
|
+
>;
|
|
382
|
+
|
|
383
|
+
expect(result.billedCostUsd).toBeCloseTo(1.1, 6);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("AC-2: markup falls back to the organization-default row", async () => {
|
|
387
|
+
seedMarkupTable([{ provider: null, model: null, markupPercent: 8 }]);
|
|
388
|
+
|
|
389
|
+
const result = (await recordUsageEvent(baseInput())) as Extract<
|
|
390
|
+
RecordUsageResult,
|
|
391
|
+
{ ok: true }
|
|
392
|
+
>;
|
|
393
|
+
|
|
394
|
+
expect(result.billedCostUsd).toBeCloseTo(1.08, 6);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("AC-2: markup falls back to the 20% system default", async () => {
|
|
398
|
+
seedMarkupTable([]);
|
|
399
|
+
|
|
400
|
+
const result = (await recordUsageEvent(baseInput())) as Extract<
|
|
401
|
+
RecordUsageResult,
|
|
402
|
+
{ ok: true }
|
|
403
|
+
>;
|
|
404
|
+
|
|
405
|
+
expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
// ── Unknown model → safe defaults, no crash ────────────────────────────────
|
|
409
|
+
it("an unresolvable model defaults to BASE / non-auto / no extended surcharge", async () => {
|
|
410
|
+
mockOf(db.llmModel.findFirst).mockResolvedValue(null);
|
|
411
|
+
|
|
412
|
+
// A large context window would trigger the surcharge IF chargeExtendedAt2x
|
|
413
|
+
// resolved true — for an unknown model it must default false.
|
|
414
|
+
const result = (await recordUsageEvent(
|
|
415
|
+
baseInput({ contextWindowSize: 800_000 }),
|
|
416
|
+
)) as Extract<RecordUsageResult, { ok: true }>;
|
|
417
|
+
|
|
418
|
+
expect(result.billedCostUsd).toBeCloseTo(1.2, 6); // no 2x surcharge
|
|
419
|
+
expect(createdEventData()).toEqual(
|
|
420
|
+
expect.objectContaining({ modelTier: "BASE", wasAutoModel: false }),
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ── PII non-leak — the result never carries token content ──────────────────
|
|
425
|
+
it("PII/content non-leak — the result carries only ids and computed costs", async () => {
|
|
426
|
+
const result = await recordUsageEvent(baseInput());
|
|
427
|
+
|
|
428
|
+
expect(Object.keys(result)).toEqual(
|
|
429
|
+
expect.arrayContaining(["ok", "eventId", "billedCostUsd"]),
|
|
430
|
+
);
|
|
431
|
+
expect(JSON.stringify(result).length).toBeLessThan(200);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// ══ H3 — atomicity: the event + the aggregate are ONE transaction ══════════
|
|
435
|
+
describe("H3 — atomic write (no double-billing on retry)", () => {
|
|
436
|
+
it("a failed UsagePeriod upsert persists NO UsageEvent — a retry cannot double-bill", async () => {
|
|
437
|
+
// The P2002 upsert race: two concurrent calls create the same
|
|
438
|
+
// (organizationId, periodStart) row; the loser throws.
|
|
439
|
+
installAtomicDb({
|
|
440
|
+
upsertError: new Error(
|
|
441
|
+
"Unique constraint failed on the fields: (`organizationId`,`periodStart`)",
|
|
442
|
+
),
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const result = await recordUsageEvent(baseInput());
|
|
446
|
+
|
|
447
|
+
expect(result.ok).toBe(false);
|
|
448
|
+
// THE assertion: a non-transactional implementation commits the event
|
|
449
|
+
// before the upsert runs, stranding an orphan row here — and the caller's
|
|
450
|
+
// retry then writes a SECOND event, billing the org twice for one AI call.
|
|
451
|
+
expect(committedEvents).toHaveLength(0);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("a write failure reports persisted:false — a retry is always safe", async () => {
|
|
455
|
+
installAtomicDb({ upsertError: new Error("connection reset") });
|
|
456
|
+
|
|
457
|
+
const result = await recordUsageEvent(baseInput());
|
|
458
|
+
|
|
459
|
+
const err = result as Extract<RecordUsageResult, { ok: false }>;
|
|
460
|
+
expect(err.error.kind).toBe("write_failed");
|
|
461
|
+
expect(err.error.persisted).toBe(false);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("both writes go through $transaction — neither is reachable off the base client", async () => {
|
|
465
|
+
// A transaction that cannot even be opened must produce ZERO writes:
|
|
466
|
+
// proof that the writes live only inside the transaction callback.
|
|
467
|
+
mockOf(db.$transaction).mockImplementation(async () => {
|
|
468
|
+
throw new Error("could not start transaction");
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
const result = await recordUsageEvent(baseInput());
|
|
472
|
+
|
|
473
|
+
expect(result.ok).toBe(false);
|
|
474
|
+
expect(db.usageEvent.create).not.toHaveBeenCalled();
|
|
475
|
+
expect(db.usagePeriod.upsert).not.toHaveBeenCalled();
|
|
476
|
+
expect(committedEvents).toHaveLength(0);
|
|
477
|
+
expect(db.$transaction).toHaveBeenCalledTimes(1);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("the happy path commits exactly one event, through the transaction", async () => {
|
|
481
|
+
const result = await recordUsageEvent(baseInput());
|
|
482
|
+
|
|
483
|
+
expect(result.ok).toBe(true);
|
|
484
|
+
expect(db.$transaction).toHaveBeenCalledTimes(1);
|
|
485
|
+
expect(committedEvents).toHaveLength(1);
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
// ══ H4 — boundary validation: no negative/NaN/Infinity reaches cost or DB ══
|
|
490
|
+
describe("H4 — input validation (invalid_input)", () => {
|
|
491
|
+
it("a NEGATIVE token count is rejected — it cannot decrement the org's spend", async () => {
|
|
492
|
+
const result = await recordUsageEvent(
|
|
493
|
+
baseInput({ inputTokensNoCacheWrite: -1_000_000 }),
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
expectInvalidInput(result);
|
|
497
|
+
// The attack: a negative cost → a negative usageSpendCents increment →
|
|
498
|
+
// the org's metered spend is DECREMENTED (its bill zeroed out).
|
|
499
|
+
expect(db.$transaction).not.toHaveBeenCalled();
|
|
500
|
+
expect(db.usageEvent.create).not.toHaveBeenCalled();
|
|
501
|
+
expect(db.usagePeriod.upsert).not.toHaveBeenCalled();
|
|
502
|
+
expect(committedEvents).toHaveLength(0);
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("NaN is rejected — Postgres double precision accepts it and the aggregate is poisoned forever", async () => {
|
|
506
|
+
const result = await recordUsageEvent(baseInput({ outputTokens: Number.NaN }));
|
|
507
|
+
|
|
508
|
+
expectInvalidInput(result);
|
|
509
|
+
expect(db.usageEvent.create).not.toHaveBeenCalled();
|
|
510
|
+
expect(db.usagePeriod.upsert).not.toHaveBeenCalled();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("Infinity is rejected", async () => {
|
|
514
|
+
const result = await recordUsageEvent(
|
|
515
|
+
baseInput({ outputTokens: Number.POSITIVE_INFINITY }),
|
|
516
|
+
);
|
|
517
|
+
|
|
518
|
+
expectInvalidInput(result);
|
|
519
|
+
expect(db.usageEvent.create).not.toHaveBeenCalled();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("a non-integer token count is rejected", async () => {
|
|
523
|
+
const result = await recordUsageEvent(baseInput({ outputTokens: 1.5 }));
|
|
524
|
+
|
|
525
|
+
expectInvalidInput(result);
|
|
526
|
+
expect(db.usageEvent.create).not.toHaveBeenCalled();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it("an absurd token magnitude is rejected (overflow guard)", async () => {
|
|
530
|
+
const result = await recordUsageEvent(
|
|
531
|
+
baseInput({ inputTokensNoCacheWrite: 1e15 }),
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
expectInvalidInput(result);
|
|
535
|
+
expect(db.usageEvent.create).not.toHaveBeenCalled();
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it("a negative contextWindowSize is rejected", async () => {
|
|
539
|
+
const result = await recordUsageEvent(baseInput({ contextWindowSize: -1 }));
|
|
540
|
+
|
|
541
|
+
expectInvalidInput(result);
|
|
542
|
+
expect(db.usageEvent.create).not.toHaveBeenCalled();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("the error names the offending field", async () => {
|
|
546
|
+
const result = await recordUsageEvent(baseInput({ outputTokens: -5 }));
|
|
547
|
+
|
|
548
|
+
const err = result as Extract<RecordUsageResult, { ok: false }>;
|
|
549
|
+
expect(err.error.message).toContain("outputTokens");
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// ══ H5 — tenant grain: branded ids, validated at the boundary ══════════════
|
|
554
|
+
describe("H5 — tenant grain", () => {
|
|
555
|
+
it("rejects a MISMATCHED grain — a workspace id smuggled into the organization slot", async () => {
|
|
556
|
+
// organizationId keys BOTH the markup lookup and the spend increment, so a
|
|
557
|
+
// wrong id here bills the wrong tenant (and leaks its markup back).
|
|
558
|
+
const badTenant = forgedTenant({ organizationId: WORKSPACE_ID });
|
|
559
|
+
|
|
560
|
+
const result = await recordUsageEvent(baseInput({ tenant: badTenant }));
|
|
561
|
+
|
|
562
|
+
expectInvalidInput(result);
|
|
563
|
+
expect(db.$transaction).not.toHaveBeenCalled();
|
|
564
|
+
expect(db.aiMarkupConfig.findFirst).not.toHaveBeenCalled();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it("rejects an arbitrary caller-supplied string as an organization id", async () => {
|
|
568
|
+
const badTenant = forgedTenant({ organizationId: "victim-org" });
|
|
569
|
+
|
|
570
|
+
const result = await recordUsageEvent(baseInput({ tenant: badTenant }));
|
|
571
|
+
|
|
572
|
+
expectInvalidInput(result);
|
|
573
|
+
expect(db.usageEvent.create).not.toHaveBeenCalled();
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("rejects a malformed workspace id", async () => {
|
|
577
|
+
const badTenant = forgedTenant({ workspaceId: "wksp_not-a-uuid" });
|
|
578
|
+
|
|
579
|
+
const result = await recordUsageEvent(baseInput({ tenant: badTenant }));
|
|
580
|
+
|
|
581
|
+
expectInvalidInput(result);
|
|
582
|
+
expect(db.usageEvent.create).not.toHaveBeenCalled();
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
it("persists the tenant grain exactly as resolved", async () => {
|
|
586
|
+
await recordUsageEvent(baseInput());
|
|
587
|
+
|
|
588
|
+
expect(createdEventData()).toEqual(
|
|
589
|
+
expect.objectContaining({
|
|
590
|
+
organizationId: ORG_ID,
|
|
591
|
+
workspaceId: WORKSPACE_ID,
|
|
592
|
+
userId: USER_ID,
|
|
593
|
+
}),
|
|
594
|
+
);
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// ══ M1 — totalTokens is DERIVED, never caller-supplied ═════════════════════
|
|
599
|
+
describe("M1 — totalTokens derived server-side", () => {
|
|
600
|
+
it("derives totalTokens from the buckets — a caller cannot bill without burning quota", async () => {
|
|
601
|
+
await recordUsageEvent(
|
|
602
|
+
baseInput({
|
|
603
|
+
inputTokensCacheWrite: 1_000,
|
|
604
|
+
inputTokensNoCacheWrite: 10_000,
|
|
605
|
+
cacheReadTokens: 2_000,
|
|
606
|
+
outputTokens: 500,
|
|
607
|
+
thinkingTokens: 100,
|
|
608
|
+
}),
|
|
609
|
+
);
|
|
610
|
+
|
|
611
|
+
const total = 1_000 + 10_000 + 2_000 + 500 + 100; // 13_600
|
|
612
|
+
expect(createdEventData()).toEqual(
|
|
613
|
+
expect.objectContaining({ totalTokens: total }),
|
|
614
|
+
);
|
|
615
|
+
// The quota surface: tokensConsumed + the tier column track the derived sum.
|
|
616
|
+
expect(upsertArgs().create).toEqual(
|
|
617
|
+
expect.objectContaining({
|
|
618
|
+
tokensConsumed: total,
|
|
619
|
+
baseTokensConsumed: total,
|
|
620
|
+
}),
|
|
621
|
+
);
|
|
622
|
+
expect(upsertArgs().update).toEqual(
|
|
623
|
+
expect.objectContaining({
|
|
624
|
+
tokensConsumed: { increment: total },
|
|
625
|
+
baseTokensConsumed: { increment: total },
|
|
626
|
+
}),
|
|
627
|
+
);
|
|
628
|
+
});
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
// ══ M2 — markup + pricing range validation ═════════════════════════════════
|
|
632
|
+
describe("M2 — markup/pricing range validation", () => {
|
|
633
|
+
it("a NULL markup column does not become a silent 0% (lost revenue) — falls through to 20%", async () => {
|
|
634
|
+
seedMarkupTable([{ provider: PROVIDER, model: MODEL, markupPercent: null }]);
|
|
635
|
+
|
|
636
|
+
const result = (await recordUsageEvent(baseInput())) as Extract<
|
|
637
|
+
RecordUsageResult,
|
|
638
|
+
{ ok: true }
|
|
639
|
+
>;
|
|
640
|
+
|
|
641
|
+
// Number(null) === 0 would bill at cost. The system default must win.
|
|
642
|
+
expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
|
|
643
|
+
expect(createdEventData()).toEqual(
|
|
644
|
+
expect.objectContaining({ markupPercent: 20 }),
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("a NEGATIVE markup row never bills below provider cost", async () => {
|
|
649
|
+
seedMarkupTable([{ provider: PROVIDER, model: MODEL, markupPercent: -50 }]);
|
|
650
|
+
|
|
651
|
+
const result = (await recordUsageEvent(baseInput())) as Extract<
|
|
652
|
+
RecordUsageResult,
|
|
653
|
+
{ ok: true }
|
|
654
|
+
>;
|
|
655
|
+
|
|
656
|
+
expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("a non-numeric markup row falls through to the default", async () => {
|
|
660
|
+
seedMarkupTable([
|
|
661
|
+
{ provider: PROVIDER, model: MODEL, markupPercent: "not-a-number" },
|
|
662
|
+
]);
|
|
663
|
+
|
|
664
|
+
const result = (await recordUsageEvent(baseInput())) as Extract<
|
|
665
|
+
RecordUsageResult,
|
|
666
|
+
{ ok: true }
|
|
667
|
+
>;
|
|
668
|
+
|
|
669
|
+
expect(result.billedCostUsd).toBeCloseTo(1.2, 6);
|
|
670
|
+
expect(Number.isFinite(createdEventData().markupPercent as number)).toBe(true);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("an unusable model-specific row falls through the chain to the provider-wide row", async () => {
|
|
674
|
+
seedMarkupTable([
|
|
675
|
+
{ provider: PROVIDER, model: MODEL, markupPercent: null },
|
|
676
|
+
{ provider: PROVIDER, model: null, markupPercent: 10 },
|
|
677
|
+
]);
|
|
678
|
+
|
|
679
|
+
const result = (await recordUsageEvent(baseInput())) as Extract<
|
|
680
|
+
RecordUsageResult,
|
|
681
|
+
{ ok: true }
|
|
682
|
+
>;
|
|
683
|
+
|
|
684
|
+
expect(result.billedCostUsd).toBeCloseTo(1.1, 6);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("a corrupt pricing rate never reaches the cost math (no NaN in the DB)", async () => {
|
|
688
|
+
seedPricing({ inputPricePerMillion: "corrupt" });
|
|
689
|
+
|
|
690
|
+
const result = (await recordUsageEvent(baseInput())) as Extract<
|
|
691
|
+
RecordUsageResult,
|
|
692
|
+
{ ok: true }
|
|
693
|
+
>;
|
|
694
|
+
|
|
695
|
+
expect(result.billedCostUsd).toBe(0);
|
|
696
|
+
expect(Number.isFinite(createdEventData().providerCostUsd as number)).toBe(true);
|
|
697
|
+
expect(Number.isFinite(upsertArgs().create.usageSpendCents as number)).toBe(true);
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
// ══ M3 — unvalidated tier → an "undefined" column key ══════════════════════
|
|
702
|
+
describe("M3 — tier validation", () => {
|
|
703
|
+
it("an out-of-union tier defaults to BASE — never an 'undefined' upsert key", async () => {
|
|
704
|
+
seedModel({ tier: "LEGENDARY" }); // a tier the catalog should never hold
|
|
705
|
+
|
|
706
|
+
const result = await recordUsageEvent(baseInput());
|
|
707
|
+
|
|
708
|
+
expect(result.ok).toBe(true);
|
|
709
|
+
expect(upsertArgs().create).toEqual(
|
|
710
|
+
expect.objectContaining({ baseTokensConsumed: 1_000_000 }),
|
|
711
|
+
);
|
|
712
|
+
expect(upsertArgs().create).not.toHaveProperty("undefined");
|
|
713
|
+
expect(upsertArgs().update).not.toHaveProperty("undefined");
|
|
714
|
+
expect(createdEventData()).toEqual(
|
|
715
|
+
expect.objectContaining({ modelTier: "BASE" }),
|
|
716
|
+
);
|
|
717
|
+
});
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// ══ M4 — missing pricing is MARKED, not silent ═════════════════════════════
|
|
721
|
+
describe("M4 — missing pricing", () => {
|
|
722
|
+
it("meters at $0 without blocking, and MARKS the event so it can be re-priced", async () => {
|
|
723
|
+
mockOf(db.llmModelPricing.findFirst).mockResolvedValue(null);
|
|
724
|
+
|
|
725
|
+
const result = await recordUsageEvent(baseInput());
|
|
726
|
+
|
|
727
|
+
expect(result).toEqual<RecordUsageResult>({
|
|
728
|
+
ok: true,
|
|
729
|
+
eventId: "evt_1",
|
|
730
|
+
billedCostUsd: 0,
|
|
731
|
+
});
|
|
732
|
+
const data = createdEventData();
|
|
733
|
+
expect(data).toEqual(
|
|
734
|
+
expect.objectContaining({ providerCostUsd: 0, billedCostUsd: 0 }),
|
|
735
|
+
);
|
|
736
|
+
// Distinguishable from a genuine $0 call — and the caller's status survives.
|
|
737
|
+
expect(data.completionStatus).toBe("pricing_missing:success");
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
it("a priced call carries the caller's status unmarked", async () => {
|
|
741
|
+
await recordUsageEvent(baseInput({ completionStatus: "error" }));
|
|
742
|
+
|
|
743
|
+
expect(createdEventData().completionStatus).toBe("error");
|
|
744
|
+
});
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
// ══ M5 — raw DB error text is never returned ═══════════════════════════════
|
|
748
|
+
describe("M5 — error propagation", () => {
|
|
749
|
+
it("does not leak the raw Prisma message (which embeds ids + token counts)", async () => {
|
|
750
|
+
// A Prisma validation error embeds the whole `data` argument.
|
|
751
|
+
const leaky = new Error(
|
|
752
|
+
`Invalid \`prisma.usageEvent.create()\` invocation: { organizationId: "${ORG_ID}", userId: "${USER_ID}", inputTokensNoCacheWrite: 1000000 }`,
|
|
753
|
+
);
|
|
754
|
+
installAtomicDb({ upsertError: leaky });
|
|
755
|
+
|
|
756
|
+
const result = await recordUsageEvent(baseInput());
|
|
757
|
+
|
|
758
|
+
const err = result as Extract<RecordUsageResult, { ok: false }>;
|
|
759
|
+
expect(err.error.kind).toBe("write_failed");
|
|
760
|
+
expect(err.error.message).toBe("usage event write failed");
|
|
761
|
+
expect(err.error.message).not.toContain(ORG_ID);
|
|
762
|
+
expect(err.error.message).not.toContain(USER_ID);
|
|
763
|
+
expect(err.error.message).not.toContain("prisma");
|
|
764
|
+
// The detail is still available to operators — server-side only.
|
|
765
|
+
expect(console.error).toHaveBeenCalled();
|
|
766
|
+
});
|
|
767
|
+
});
|
|
768
|
+
});
|