create-ailk 0.5.0 → 0.6.1
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 +55 -8
- package/component-catalog.json +336 -27
- package/component-catalog.md +25 -12
- package/dist/cli.js +54 -5
- package/dist/copy.d.ts +14 -0
- package/dist/copy.js +38 -1
- package/dist/lib/api-boot-assertion.d.ts +46 -0
- package/dist/lib/api-boot-assertion.js +70 -0
- package/dist/lib/extract-module-bundle.d.ts +20 -1
- package/dist/lib/extract-module-bundle.js +64 -5
- package/dist/lib/import-graph.d.ts +29 -0
- package/dist/lib/import-graph.js +140 -0
- package/dist/lib/module-license-gate.d.ts +6 -1
- package/dist/lib/module-license-gate.js +6 -1
- package/dist/lib/pro-callout.js +1 -1
- package/dist/module-architecture.d.ts +1 -0
- package/dist/module-architecture.js +253 -127
- package/dist/modules/commerce/0.6.0.tar.gz +0 -0
- package/dist/modules/docs-blog/0.6.0.tar.gz +0 -0
- package/dist/modules/multi-tenant/0.6.0.tar.gz +0 -0
- package/dist/modules/scheduling/0.6.0.tar.gz +0 -0
- package/dist/modules/signal-collectors/0.6.0.tar.gz +0 -0
- package/dist/modules/workspace/0.6.0.tar.gz +0 -0
- package/dist/programmatic.js +14 -10
- package/dist/surfaces.js +105 -7
- package/dist/template-manifest.d.ts +33 -0
- package/dist/template-manifest.js +99 -16
- package/dist/template-names.d.ts +1 -1
- package/dist/template-names.js +1 -0
- package/package.json +3 -2
- package/site-templates/marketing-site/DESIGN.md +148 -0
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +22 -2
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +9 -2
- package/site-templates/marketing-site/ailk-template.json +1 -1
- package/site-templates/marketing-site-free/DESIGN.md +148 -0
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +2 -2
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +1 -0
- package/site-templates/minimalism-site/DESIGN.md +148 -0
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.json +10 -2
- package/site-templates/minimalism-site/TEMPLATE-MANIFEST.md +47 -35
- package/site-templates/minimalism-site/ailk-template.json +2 -1
- package/site-templates/minimalism-site/assets/wordmark-placeholder.svg +28 -0
- package/site-templates/minimalism-site/content/en/HomePage/home.mdx +49 -19
- package/site-templates/saas-product/DESIGN.md +148 -0
- package/site-templates/simple-service/DESIGN.md +148 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.json +240 -0
- package/site-templates/simple-service/TEMPLATE-MANIFEST.md +278 -0
- package/site-templates/simple-service/ailk-template.json +10 -0
- package/site-templates/simple-service/content/en/AboutPage/about.mdx +38 -0
- package/site-templates/simple-service/content/en/ContactPage/contact.mdx +67 -0
- package/site-templates/simple-service/content/en/FAQPage/faq.mdx +56 -0
- package/site-templates/simple-service/content/en/HomePage/home.mdx +85 -0
- package/site-templates/simple-service/content/en/Service/landscape-design.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/lawn-maintenance.mdx +40 -0
- package/site-templates/simple-service/content/en/Service/seasonal-cleanup.mdx +40 -0
- package/site-templates/simple-service/content/en/ServicesList/services.mdx +72 -0
- package/site-templates/simple-service/provenance.json +4 -0
- package/templates/.env.example +72 -7
- package/templates/CLAUDE.md +2 -3
- package/templates/DESIGN.md +155 -0
- package/templates/README.md +42 -8
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-31.552Z-fallback-1789916251590-037e9005.json +8 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.840Z-fallback-1789916253842-b52659e9.json +12 -0
- package/templates/apps/api/.ailk/inbox/2026-09-20T14-57-33.844Z-fallback-1789916253845-6b266f8c.json +12 -0
- package/templates/apps/api/.env.example +13 -0
- package/templates/apps/api/CLAUDE.md +26 -2
- package/templates/apps/api/jest.config.cjs +3 -0
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/commerce-claim-registration.test.ts +407 -0
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +83 -7
- package/templates/apps/api/src/__tests__/server.test.ts +471 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +45 -0
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +2 -0
- package/templates/apps/api/src/config/index.ts +62 -0
- package/templates/apps/api/src/config/modules.ts +7 -3
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +238 -2
- package/templates/apps/api/src/lib/__tests__/auth-email-budget.test.ts +143 -0
- package/templates/apps/api/src/lib/__tests__/content-adapter.test.ts +23 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +125 -0
- package/templates/apps/api/src/lib/__tests__/error-serializer.test.ts +20 -0
- package/templates/apps/api/src/lib/__tests__/require-commerce-claim.test.ts +317 -0
- package/templates/apps/api/src/lib/__tests__/site.test.ts +238 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +75 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +160 -0
- package/templates/apps/api/src/lib/content-adapter.ts +57 -31
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +76 -0
- package/templates/apps/api/src/lib/error-serializer.ts +23 -3
- package/templates/apps/api/src/lib/outbound-http.ts +7 -2
- package/templates/apps/api/src/lib/require-commerce-claim.ts +250 -0
- package/templates/apps/api/src/lib/site.ts +208 -9
- package/templates/apps/api/src/lib/stripe.ts +66 -0
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +182 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +42 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +7 -1
- package/templates/apps/api/src/openapi/spec.ts +543 -2
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +48 -0
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +309 -0
- package/templates/apps/api/src/routes/auth/__tests__/user.test.ts +123 -0
- package/templates/apps/api/src/routes/auth/transactional-email.ts +273 -0
- package/templates/apps/api/src/routes/auth/user.ts +61 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +434 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +888 -0
- package/templates/apps/api/src/routes/checkout/index.ts +34 -0
- package/templates/apps/api/src/routes/checkout/session.ts +139 -0
- package/templates/apps/api/src/routes/checkout/sessions.ts +197 -0
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +554 -0
- package/templates/apps/api/src/routes/content/__tests__/list-pages.test.ts +108 -0
- package/templates/apps/api/src/routes/content/__tests__/search.test.ts +450 -0
- package/templates/apps/api/src/routes/content/get-page.ts +94 -12
- package/templates/apps/api/src/routes/content/index.ts +13 -73
- package/templates/apps/api/src/routes/content/list-pages.ts +15 -3
- package/templates/apps/api/src/routes/content/rate-limit.ts +15 -6
- package/templates/apps/api/src/routes/content/search.ts +129 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +447 -31
- package/templates/apps/api/src/routes/leads/index.ts +100 -13
- package/templates/apps/api/src/routes/project-listings/__tests__/configured-application.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +351 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/founder-identity.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +5 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/public.test.ts +4 -1
- package/templates/apps/api/src/routes/project-listings/__tests__/resume-email.test.ts +58 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/site-answers.test.ts +107 -6
- package/templates/apps/api/src/routes/project-listings/__tests__/site-key.test.ts +87 -2
- package/templates/apps/api/src/routes/project-listings/__tests__/structured-address.test.ts +51 -0
- package/templates/apps/api/src/routes/project-listings/start.ts +5 -1
- package/templates/apps/api/src/routes/requests/__tests__/confirm.test.ts +686 -0
- package/templates/apps/api/src/routes/requests/confirm.ts +601 -0
- package/templates/apps/api/src/routes/requests/index.ts +54 -0
- package/templates/apps/api/src/routes/webhooks/README.md +265 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-dunning.test.ts +749 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +330 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +262 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +285 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +213 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-request-paid.test.ts +301 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +648 -0
- package/templates/apps/api/src/routes/webhooks/index.ts +89 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +1399 -0
- package/templates/apps/api/src/server.ts +439 -49
- package/templates/apps/api/src/services/__tests__/abandon-email.test.ts +39 -0
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +217 -40
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +620 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +2 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +300 -5
- package/templates/apps/api/src/services/__tests__/deliverable-resend.test.ts +20 -0
- package/templates/apps/api/src/services/__tests__/dunning-policy.test.ts +368 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +160 -0
- package/templates/apps/api/src/services/__tests__/listing-config.test.ts +166 -0
- package/templates/apps/api/src/services/__tests__/project-listings.fence.test.ts +154 -0
- package/templates/apps/api/src/services/__tests__/public-site-resolver.test.ts +137 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +444 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +238 -0
- package/templates/apps/api/src/services/__tests__/verified-email.test.ts +78 -0
- package/templates/apps/api/src/services/abandon-email.ts +8 -2
- package/templates/apps/api/src/services/aeo-score.ts +49 -12
- package/templates/apps/api/src/services/audit-log.ts +668 -0
- package/templates/apps/api/src/services/consent.ts +18 -0
- package/templates/apps/api/src/services/default-tenant.ts +70 -31
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +141 -13
- package/templates/apps/api/src/services/dunning-policy.ts +294 -0
- package/templates/apps/api/src/services/entitlements.ts +80 -0
- package/templates/apps/api/src/services/lead-promotion.ts +13 -14
- package/templates/apps/api/src/services/lead-routing.ts +161 -2
- package/templates/apps/api/src/services/leads-inbox.ts +23 -2
- package/templates/apps/api/src/services/listing-config.ts +27 -9
- package/templates/apps/api/src/services/project-listings.ts +201 -6
- package/templates/apps/api/src/services/public-site-resolver.ts +106 -0
- package/templates/apps/api/src/services/request-confirm.ts +646 -0
- package/templates/apps/api/src/services/revalidation.ts +173 -0
- package/templates/apps/api/src/services/verified-email.ts +71 -0
- package/templates/apps/api/src/vercel-handler.ts +75 -35
- package/templates/apps/mcp/.env.example +17 -0
- package/templates/apps/mcp/CLAUDE.md +57 -24
- package/templates/apps/mcp/__tests__/aeo-cli-parity.test.ts +6 -0
- package/templates/apps/mcp/__tests__/audit.test.ts +137 -8
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +79 -36
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +111 -2
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +82 -2
- package/templates/apps/mcp/__tests__/http-transport.test.ts +541 -2
- package/templates/apps/mcp/__tests__/index.test.ts +39 -0
- package/templates/apps/mcp/__tests__/lib/audit-single-page-readiness-scan.test.ts +143 -0
- package/templates/apps/mcp/__tests__/lib/commerce-claim.test.ts +111 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter-parity.test.ts +65 -0
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +188 -0
- package/templates/apps/mcp/__tests__/lib/fail-closed-tools.test.ts +165 -0
- package/templates/apps/mcp/__tests__/lib/public-workspace.test.ts +124 -0
- package/templates/apps/mcp/__tests__/list_pages.test.ts +116 -0
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +7 -1
- package/templates/apps/mcp/__tests__/parity.test.ts +10 -7
- package/templates/apps/mcp/__tests__/purchase-disabled.test.ts +89 -22
- package/templates/apps/mcp/__tests__/read_content.test.ts +322 -1
- package/templates/apps/mcp/__tests__/search_content.test.ts +105 -2
- package/templates/apps/mcp/__tests__/server.test.ts +67 -14
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +252 -0
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +300 -123
- package/templates/apps/mcp/jest.config.cjs +13 -0
- package/templates/apps/mcp/package.json +2 -0
- package/templates/apps/mcp/src/abuse/index.ts +159 -24
- package/templates/apps/mcp/src/config/modules.ts +10 -5
- package/templates/apps/mcp/src/http-transport.ts +102 -29
- package/templates/apps/mcp/src/index.ts +73 -11
- package/templates/apps/mcp/src/lib/audit-single-page.ts +90 -3
- package/templates/apps/mcp/src/lib/commerce-claim.ts +94 -0
- package/templates/apps/mcp/src/lib/content-adapter.ts +113 -0
- package/templates/apps/mcp/src/lib/empty-content-adapter.ts +139 -0
- package/templates/apps/mcp/src/lib/prisma.ts +39 -0
- package/templates/apps/mcp/src/lib/public-workspace.ts +133 -0
- package/templates/apps/mcp/src/lib/stripe-tax-signal.ts +59 -0
- package/templates/apps/mcp/src/server.ts +59 -35
- package/templates/apps/mcp/src/tools/audit.ts +26 -2
- package/templates/apps/mcp/src/tools/get_business_hours.ts +4 -3
- package/templates/apps/mcp/src/tools/get_faq.ts +4 -3
- package/templates/apps/mcp/src/tools/get_locations.ts +5 -3
- package/templates/apps/mcp/src/tools/get_offerings.ts +16 -4
- package/templates/apps/mcp/src/tools/get_pricing.ts +25 -6
- package/templates/apps/mcp/src/tools/get_site_info.ts +4 -3
- package/templates/apps/mcp/src/tools/index.ts +6 -4
- package/templates/apps/mcp/src/tools/list_pages.ts +4 -3
- package/templates/apps/mcp/src/tools/purchase.ts +128 -45
- package/templates/apps/mcp/src/tools/read_content.ts +20 -6
- package/templates/apps/mcp/src/tools/search_content.ts +11 -4
- package/templates/apps/web/.env.example +71 -5
- package/templates/apps/web/CLAUDE.md +44 -0
- package/templates/apps/web/PRODUCT.md +86 -0
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +18 -2
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +11 -3
- package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +282 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +515 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +89 -0
- package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +109 -0
- package/templates/apps/web/app/[locale]/(auth)/layout.tsx +92 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +135 -0
- package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +109 -0
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/page.test.tsx +48 -2
- package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/page.tsx +20 -6
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +125 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +101 -0
- package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +102 -0
- package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +87 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +20 -10
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +84 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +58 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +63 -8
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +18 -1
- package/templates/apps/web/app/[locale]/cancellation/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/layout.tsx +133 -87
- package/templates/apps/web/app/[locale]/purchase/__tests__/cancel.test.tsx +162 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/module-scope.test.ts +68 -0
- package/templates/apps/web/app/[locale]/purchase/__tests__/success.test.tsx +361 -0
- package/templates/apps/web/app/[locale]/purchase/cancel/page.tsx +115 -0
- package/templates/apps/web/app/[locale]/purchase/success/page.tsx +307 -0
- package/templates/apps/web/app/[locale]/returns/page.tsx +44 -0
- package/templates/apps/web/app/[locale]/shipping/page.tsx +44 -0
- package/templates/apps/web/app/api/revalidate/__tests__/route.test.ts +670 -0
- package/templates/apps/web/app/api/revalidate/route.ts +194 -0
- package/templates/apps/web/auth.ts +407 -8
- package/templates/apps/web/jest.config.cjs +8 -4
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +214 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +74 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +72 -0
- package/templates/apps/web/lib/__tests__/site-theme.test.ts +135 -20
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +18 -1
- package/templates/apps/web/lib/auth-email.ts +318 -0
- package/templates/apps/web/lib/auth-env.ts +85 -0
- package/templates/apps/web/lib/auth-routes.ts +55 -0
- package/templates/apps/web/lib/meeting-slugs.ts +53 -0
- package/templates/apps/web/lib/site-brand.tsx +4 -8
- package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
- package/templates/apps/web/messages/en.json +187 -0
- package/templates/apps/web/middleware.ts +1 -1
- package/templates/apps/web/package.json +2 -1
- package/templates/apps/web/src/lib/commerce-claim.ts +33 -0
- package/templates/apps/web/src/lib/commerce-sections.ts +93 -0
- package/templates/content/_site.mdx +18 -3
- package/templates/content/en/CancellationPolicyPage/cancellation.mdx +108 -0
- package/templates/content/en/ReturnsPolicyPage/returns.mdx +108 -0
- package/templates/content/en/ShippingPolicyPage/shipping.mdx +108 -0
- package/templates/database/CHANGELOG.md +149 -0
- package/templates/database/README.md +10 -3
- package/templates/database/__tests__/appointment.db.test.ts +106 -0
- package/templates/database/__tests__/artifact-content-store.db.test.ts +316 -0
- package/templates/database/__tests__/composite-fence.db.test.ts +206 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +711 -0
- package/templates/database/__tests__/fixtures/appointment-create-probe.ts +79 -0
- package/templates/database/__tests__/fixtures/project-listing-consent-probe.ts +100 -0
- package/templates/database/__tests__/flow-workspace-key.db.test.ts +263 -0
- package/templates/database/__tests__/prefixed-ids.db.test.ts +85 -9
- package/templates/database/__tests__/project-listing-consent-at.db.test.ts +111 -0
- package/templates/database/__tests__/tenancy.test.ts +141 -0
- package/templates/database/config/schema.prisma +79 -2
- package/templates/database/content/schema.prisma +87 -11
- package/templates/database/inbox/schema.prisma +452 -9
- package/templates/database/jest.config.cjs +5 -3
- package/templates/database/migrations/20260915120000_project_listing_solution/migration.sql +15 -0
- package/templates/database/migrations/20260916120000_artifact_content_store/migration.sql +158 -0
- package/templates/database/migrations/20260917120000_callback_request_confirm/migration.sql +40 -0
- package/templates/database/migrations/20260918120000_add_appointment/migration.sql +67 -0
- package/templates/database/migrations/20260918120000_asset_metadata_columns/migration.sql +70 -0
- package/templates/database/migrations/20260919120000_flow_workspace_key/migration.sql +181 -0
- package/templates/database/migrations/20260919130000_flow_definition_admin_columns/migration.sql +172 -0
- package/templates/database/migrations/20260919130000_workspace_meetings/migration.sql +41 -0
- package/templates/database/migrations/20260919140000_inbox_workspace_key/migration.sql +185 -0
- package/templates/database/migrations/20260920120000_lead_handled_at/migration.sql +70 -0
- package/templates/database/migrations/20260920130000_lead_workspace_source_index/migration.sql +57 -0
- package/templates/database/migrations/20260921120000_deliverable_grant_workspace_key/migration.sql +68 -0
- package/templates/database/migrations/20260922120000_tenancy_fence/migration.sql +1067 -0
- package/templates/database/migrations/20260923120000_listing_capturing_magnet/migration.sql +210 -0
- package/templates/database/package.json +3 -2
- package/templates/database/tenancy.ts +86 -1
- package/templates/database/tsconfig.json +1 -0
- package/versions.json +34 -34
- package/templates/apps/api/src/routes/content/__tests__/create.test.ts +0 -253
- package/templates/apps/api/src/routes/content/__tests__/delete.test.ts +0 -215
- package/templates/apps/api/src/routes/content/__tests__/update.test.ts +0 -266
- package/templates/apps/api/src/routes/content/create.ts +0 -115
- package/templates/apps/api/src/routes/content/delete.ts +0 -82
- package/templates/apps/api/src/routes/content/update.ts +0 -112
- package/templates/apps/mcp/__tests__/create_page.test.ts +0 -202
- package/templates/apps/mcp/__tests__/delete_page.test.ts +0 -177
- package/templates/apps/mcp/__tests__/update_page.test.ts +0 -196
- package/templates/apps/mcp/src/tools/create_page.ts +0 -96
- package/templates/apps/mcp/src/tools/delete_page.ts +0 -74
- package/templates/apps/mcp/src/tools/update_page.ts +0 -92
- package/templates/apps/web/app/[locale]/sign-in/sign-in-card.tsx +0 -123
- package/templates/apps/web/public/logo-dark.svg +0 -21
- package/templates/apps/web/public/logo-light.svg +0 -21
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/__tests__/next-path.test.ts +0 -0
- /package/templates/apps/web/app/[locale]/{sign-in → (auth)/sign-in}/next-path.ts +0 -0
|
@@ -27,7 +27,14 @@
|
|
|
27
27
|
* idempotency reader) + callback.findMany (booked-call suppression) +
|
|
28
28
|
* waitlistScore.findMany (tier lookup) — used by the #4732
|
|
29
29
|
* waitlist-followup sweep service tests
|
|
30
|
-
* tenancy models (workspace, membership) — used by tenant-context service tests (#3627)
|
|
30
|
+
* tenancy models (workspace, membership) — used by tenant-context service tests (#3627).
|
|
31
|
+
* workspace.findUnique carries NO default (#6335) — see its own comment
|
|
32
|
+
* below. A suite whose route resolves a workspace states the shape it
|
|
33
|
+
* expects through `mockWorkspaceExists()` / `mockNoWorkspace()`, exported
|
|
34
|
+
* at the foot of this file
|
|
35
|
+
* artifact models (artifact, artifactVersion) — the database content
|
|
36
|
+
* adapter's read surface, used by the public-site-resolver + content route
|
|
37
|
+
* tests (#5852)
|
|
31
38
|
* membership.findMany + lead.findMany/updateMany — used by lead-promotion
|
|
32
39
|
* service tests (#3586); lead-promotion.ts never queries membership/workspace
|
|
33
40
|
* directly (it resolves workspaces via tenant-context.ts's listTenancy), so
|
|
@@ -49,6 +56,10 @@
|
|
|
49
56
|
* #3739 M2 — was `findMany` + a JS accumulation loop). Same not-yet-
|
|
50
57
|
* generated-client situation; `usage-aggregation.ts` casts `prisma` to its
|
|
51
58
|
* own structural delegate type, and this mock's runtime shape matches it.
|
|
59
|
+
*
|
|
60
|
+
* userData.create/updateMany (auth) + siteSetting.findUnique (config) — the
|
|
61
|
+
* dunning record's compare-and-swap write and the per-tenant grace-policy
|
|
62
|
+
* read (#6034), used by dunning-policy.test.ts and stripe-dunning.test.ts.
|
|
52
63
|
*/
|
|
53
64
|
|
|
54
65
|
export const prisma = {
|
|
@@ -68,6 +79,13 @@ export const prisma = {
|
|
|
68
79
|
findUnique: jest.fn(),
|
|
69
80
|
upsert: jest.fn(),
|
|
70
81
|
deleteMany: jest.fn(),
|
|
82
|
+
// `create` / `updateMany` — the dunning record's compare-and-swap write
|
|
83
|
+
// (#6034 § D6): the webhook reads `{value, updatedAt}` then either
|
|
84
|
+
// `create`s a fresh row or `updateMany`s guarded on BOTH observed
|
|
85
|
+
// columns, so a concurrent writer makes the predicate miss (`count: 0`)
|
|
86
|
+
// rather than silently losing a revocation.
|
|
87
|
+
create: jest.fn(),
|
|
88
|
+
updateMany: jest.fn(),
|
|
71
89
|
},
|
|
72
90
|
// ── Tenancy namespace (Organization, Workspace, Membership) ──────────────
|
|
73
91
|
organization: {
|
|
@@ -84,6 +102,29 @@ export const prisma = {
|
|
|
84
102
|
updateMany: jest.fn(),
|
|
85
103
|
},
|
|
86
104
|
workspace: {
|
|
105
|
+
// #6335 — NO default implementation, deliberately. Until this issue the
|
|
106
|
+
// delegate carried a PERMISSIVE one (#6203): it resolved ANY requested
|
|
107
|
+
// id as an existing workspace with a sentinel-shaped organizationId, so
|
|
108
|
+
// every tenancy resolver in a route test saw "this workspace exists" no
|
|
109
|
+
// matter what the test had set up. That is DEF-014 — a fixture that
|
|
110
|
+
// fabricates the condition under test: a route that should have failed
|
|
111
|
+
// closed passed, and every isolation-shaped test written against this
|
|
112
|
+
// mock inherited a fake success.
|
|
113
|
+
//
|
|
114
|
+
// A bare `jest.fn()` resolves `undefined`, which every resolver in
|
|
115
|
+
// `apps/api/src/lib/site.ts` reads as "no such workspace" and rejects —
|
|
116
|
+
// so a suite that needs a workspace to resolve must SAY SO, keyed on the
|
|
117
|
+
// id it actually expects to be asked for:
|
|
118
|
+
//
|
|
119
|
+
// mockWorkspaceExists(prisma, SITE_ID) // only SITE_ID resolves
|
|
120
|
+
// mockSentinelWorkspaceExists(prisma) // the "default" alias path
|
|
121
|
+
// mockNoWorkspace(prisma) // nothing resolves
|
|
122
|
+
//
|
|
123
|
+
// Keying on the requested id is the load-bearing part.
|
|
124
|
+
// `resolveWorkspaceIdForAlias` returns the ALIAS on mere truthiness of
|
|
125
|
+
// the row (`site.ts`) — it never reads the row's own id — so a mock that
|
|
126
|
+
// resolves unconditionally is permissive by accident even when it hands
|
|
127
|
+
// back a specific-looking row.
|
|
87
128
|
findUnique: jest.fn(),
|
|
88
129
|
count: jest.fn(),
|
|
89
130
|
},
|
|
@@ -92,6 +133,18 @@ export const prisma = {
|
|
|
92
133
|
findMany: jest.fn(),
|
|
93
134
|
create: jest.fn(),
|
|
94
135
|
},
|
|
136
|
+
// ── Artifact store (the database content adapter's read surface, #5852) ──
|
|
137
|
+
// Only the two readers `ArtifactPrismaClient` declares for reads
|
|
138
|
+
// (packages/content-adapters/src/database/adapter.ts): the public content
|
|
139
|
+
// routes never write, so no `create`/`updateMany`/`deleteMany` is faked here.
|
|
140
|
+
artifact: {
|
|
141
|
+
findFirst: jest.fn(),
|
|
142
|
+
findMany: jest.fn(),
|
|
143
|
+
},
|
|
144
|
+
artifactVersion: {
|
|
145
|
+
findFirst: jest.fn(),
|
|
146
|
+
findMany: jest.fn(),
|
|
147
|
+
},
|
|
95
148
|
// ── Config namespace (per-workspace site config — #3641/#3634) ───────────
|
|
96
149
|
workspaceTheme: {
|
|
97
150
|
findUnique: jest.fn(),
|
|
@@ -127,9 +180,16 @@ export const prisma = {
|
|
|
127
180
|
// `findMany` — the #4732 follow-up sweep's booked-call suppression check
|
|
128
181
|
// (D2: a site's Callback rows fetched once per sweep run, filtered in
|
|
129
182
|
// application code for `payload.email` matches).
|
|
183
|
+
// findFirst / update / updateMany joined for the #5970 request-and-confirm
|
|
184
|
+
// lifecycle: the credential mint and the confirmation claim are both guarded
|
|
185
|
+
// `updateMany`s, the GET resolves through `findFirst`, and the Stripe
|
|
186
|
+
// artifact id lands via `update`.
|
|
130
187
|
callback: {
|
|
131
188
|
create: jest.fn(),
|
|
189
|
+
findFirst: jest.fn(),
|
|
132
190
|
findMany: jest.fn(),
|
|
191
|
+
update: jest.fn(),
|
|
192
|
+
updateMany: jest.fn(),
|
|
133
193
|
},
|
|
134
194
|
// ── Inbox namespace (FlowDefinition, FlowSession — #3956) ────────────────
|
|
135
195
|
// findMany on both models — used by the abandon-email sweep (#3962 D4),
|
|
@@ -137,6 +197,10 @@ export const prisma = {
|
|
|
137
197
|
// policy) and every candidate FlowSession per definition.
|
|
138
198
|
flowDefinition: {
|
|
139
199
|
findUnique: jest.fn(),
|
|
200
|
+
// #6314 — `startProjectListingDraft` resolves the capturing listicle
|
|
201
|
+
// magnet through `findFirst` (slug + siteId + workspaceId + kind), so the
|
|
202
|
+
// delegate needs it here as well as `findUnique`.
|
|
203
|
+
findFirst: jest.fn(),
|
|
140
204
|
findMany: jest.fn(),
|
|
141
205
|
upsert: jest.fn(),
|
|
142
206
|
},
|
|
@@ -156,13 +220,21 @@ export const prisma = {
|
|
|
156
220
|
},
|
|
157
221
|
// `update` — the send-outcome stamp (#3998 D1). `findMany` — the
|
|
158
222
|
// deliverable:resend CLI's list mode (#3998 D4), selecting on
|
|
159
|
-
// `sendStatus: null`.
|
|
223
|
+
// `sendStatus: null`. `workspaceId` (#6310 D15) rides `create`'s existing
|
|
224
|
+
// `data` shape — no new delegate method.
|
|
160
225
|
deliverableGrant: {
|
|
161
226
|
create: jest.fn(),
|
|
162
227
|
findUnique: jest.fn(),
|
|
163
228
|
update: jest.fn(),
|
|
164
229
|
findMany: jest.fn(),
|
|
165
230
|
},
|
|
231
|
+
// ── Content namespace (Asset — #5854/#6093, extended #6310 D16) ──────────
|
|
232
|
+
// `findFirst` — the ONLY method `lib/asset-store.ts`'s public read and
|
|
233
|
+
// `lib/deliverable-asset-store.ts`'s two scoped reads need. No write, no
|
|
234
|
+
// `findMany`, no `findUnique` — neither boundary enumerates or mutates.
|
|
235
|
+
asset: {
|
|
236
|
+
findFirst: jest.fn(),
|
|
237
|
+
},
|
|
166
238
|
// ── Inbox namespace (FlowCheckout — #4837 D4) ─────────────────────────────
|
|
167
239
|
// `create` — recordCheckout's one write; `update` — attachStripeSession;
|
|
168
240
|
// `updateMany` — the webhook's guarded status stamp (#4838); `findUnique` /
|
|
@@ -182,6 +254,19 @@ export const prisma = {
|
|
|
182
254
|
siteListingConfig: {
|
|
183
255
|
findUnique: jest.fn(),
|
|
184
256
|
},
|
|
257
|
+
// ── Config namespace (SiteSetting — #6034 § D4) ──────────────────────────
|
|
258
|
+
// `findUnique` — the ONLY read path (`dunningPolicyFor`). #6374 D3 changed
|
|
259
|
+
// its KEY SHAPE, not its arity: `site_settings` is now unique on
|
|
260
|
+
// (workspace_id, key), so the call is
|
|
261
|
+
// `findUnique({ where: { workspaceId_key: { workspaceId, key } } })` and
|
|
262
|
+
// the `key` string still carries the site alias it always did. The
|
|
263
|
+
// delegate itself is unchanged — a `jest.fn()` does not care about the
|
|
264
|
+
// selector shape — but the comment is, so a reader programming this mock
|
|
265
|
+
// writes the compound selector rather than the removed bare `key`. No
|
|
266
|
+
// route in this repo writes SiteSetting; no write delegate is mocked.
|
|
267
|
+
siteSetting: {
|
|
268
|
+
findUnique: jest.fn(),
|
|
269
|
+
},
|
|
185
270
|
// ── Inbox namespace (ProjectListing — #4998, reshaped #5033) ─────────────
|
|
186
271
|
// `create` — the POST route's row write (after routeLead's Lead write).
|
|
187
272
|
// `findFirst` — the tenant-scoped get/patch reads (scoped by siteId + id, a
|
|
@@ -257,6 +342,19 @@ export const prisma = {
|
|
|
257
342
|
createMany: jest.fn(),
|
|
258
343
|
findMany: jest.fn(),
|
|
259
344
|
},
|
|
345
|
+
// ── Inbox namespace (Appointment — #6085, brief H7) ──────────────────────
|
|
346
|
+
// `updateMany` — the § D7(a) guarded write, one per family (lifecycle /
|
|
347
|
+
// payment / no-show); `findFirst` — the disambiguating read on a zero-match
|
|
348
|
+
// updateMany (row absent vs. present-with-newer-watermark); `create` — the
|
|
349
|
+
// create-eligible arm, wrapped in the caller's own `isUniqueViolation`
|
|
350
|
+
// retry. `findMany` — the tenant-scoped GET /v1/schedule/appointments read
|
|
351
|
+
// (apps/api/src/routes/schedule/appointments.ts).
|
|
352
|
+
appointment: {
|
|
353
|
+
create: jest.fn(),
|
|
354
|
+
findFirst: jest.fn(),
|
|
355
|
+
findMany: jest.fn(),
|
|
356
|
+
updateMany: jest.fn(),
|
|
357
|
+
},
|
|
260
358
|
// ── Content namespace (UsageEvent, UsagePeriod — FEAT-047 P1.S1/#3709) ──
|
|
261
359
|
usageEvent: {
|
|
262
360
|
create: jest.fn(),
|
|
@@ -290,3 +388,141 @@ export const prisma = {
|
|
|
290
388
|
$connect: jest.fn(),
|
|
291
389
|
$disconnect: jest.fn(),
|
|
292
390
|
};
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
// ── #6335 — the explicit workspace fixtures ──────────────────────────────
|
|
394
|
+
//
|
|
395
|
+
// These replace the permissive `workspace.findUnique` default. They take the
|
|
396
|
+
// prisma double they are to program as their FIRST argument, rather than
|
|
397
|
+
// reaching for the `prisma` const above: a test importing this file directly
|
|
398
|
+
// gets its own module instance (Jest keeps manual mocks in a registry of
|
|
399
|
+
// their own, separate from the one a plain `require` of this path fills), so
|
|
400
|
+
// a helper that closed over the local `prisma` would program an object no
|
|
401
|
+
// route ever reads. Passing the double the suite already imports from
|
|
402
|
+
// `../lib/prisma.js` is what makes these work from any suite.
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* The structural shape the helpers below need — deliberately NOT
|
|
406
|
+
* `typeof prisma`, so a suite can pass the `prisma` it imported from
|
|
407
|
+
* `lib/prisma.js` (typed as the real client) with no cast at the call site.
|
|
408
|
+
*/
|
|
409
|
+
type WorkspaceMockHost = { workspace: { findUnique: unknown } };
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The sentinel Organization id the seeded default workspace hangs off
|
|
413
|
+
* (`database/tenancy.ts`'s reserved-range predicate, ADR-0031 rule 7).
|
|
414
|
+
* `resolveSentinelWorkspaceRow` rejects a default-workspace row whose
|
|
415
|
+
* `organizationId` falls outside that range, so the `"default"` alias path
|
|
416
|
+
* resolves only when the mocked row carries this value.
|
|
417
|
+
*/
|
|
418
|
+
export const SENTINEL_ORGANIZATION_ID =
|
|
419
|
+
"org_00000000-0000-7000-8000-000000000001";
|
|
420
|
+
|
|
421
|
+
/** The seeded sentinel workspace id — `DEFAULT_WORKSPACE_ID` in `database/tenancy.ts`. */
|
|
422
|
+
export const SENTINEL_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The organization an ORDINARY workspace hangs off, and deliberately NOT a
|
|
426
|
+
* sentinel one: its uuid carries non-zero timestamp bits, so
|
|
427
|
+
* `isSentinel()` (`packages/ids`, `^00000000-0000-7000-8000-…`) is false
|
|
428
|
+
* for it.
|
|
429
|
+
*
|
|
430
|
+
* Stamping the sentinel org on every mocked workspace would be its own
|
|
431
|
+
* DEF-014: `organizationId` is read off this exact row as an AUTHORIZATION
|
|
432
|
+
* input — `tenant-context.ts` denies a stale session when it disagrees with
|
|
433
|
+
* the session's active org, and `isPlatformOperator`
|
|
434
|
+
* (`waitlist-operator-reads.ts`) is `isSentinel(organizationId) && role ===
|
|
435
|
+
* "owner"`, which grants cross-SITE read scope over every site's signup
|
|
436
|
+
* PII. A helper that made every workspace sentinel-org would hand a future
|
|
437
|
+
* ordinary-tenant fixture a platform-operator identity, and would make
|
|
438
|
+
* "two workspaces in different organizations" inexpressible — so the
|
|
439
|
+
* stale-session deny branch could never be driven through it.
|
|
440
|
+
*/
|
|
441
|
+
export const ORDINARY_ORGANIZATION_ID =
|
|
442
|
+
"org_01920000-0000-7000-8000-0000000000aa";
|
|
443
|
+
|
|
444
|
+
/** One workspace row a suite says exists: an id, or an id bound to a named org. */
|
|
445
|
+
export type MockWorkspaceRow =
|
|
446
|
+
| string
|
|
447
|
+
| { readonly id: string; readonly organizationId: string };
|
|
448
|
+
|
|
449
|
+
function workspaceFindUnique(client: WorkspaceMockHost): jest.Mock {
|
|
450
|
+
const delegate = client.workspace?.findUnique;
|
|
451
|
+
if (typeof delegate !== "function" || !("mockImplementation" in delegate)) {
|
|
452
|
+
throw new Error(
|
|
453
|
+
"[#6335] prisma.workspace.findUnique is not a jest mock — the suite " +
|
|
454
|
+
"must jest.mock() its prisma module before calling this helper.",
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
return delegate as unknown as jest.Mock;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* States, for one test or one suite, EXACTLY which workspace ids exist
|
|
462
|
+
* (#6335). Every other id — a foreign one, a spoofed one, an unset one —
|
|
463
|
+
* resolves `null`, so a route that resolves a tenancy scope fails closed
|
|
464
|
+
* unless the test named the id under test.
|
|
465
|
+
*
|
|
466
|
+
* Keyed on the REQUESTED id, and that is the load-bearing part:
|
|
467
|
+
* `resolveWorkspaceIdForAlias` (`apps/api/src/lib/site.ts`) returns the
|
|
468
|
+
* ALIAS on mere truthiness of the row it gets back and never reads that
|
|
469
|
+
* row's own id, so a blanket `mockResolvedValue({ … })` resolves whatever
|
|
470
|
+
* it is handed — permissive by another name, and the rejection cases each
|
|
471
|
+
* suite now carries could not tell the two apart.
|
|
472
|
+
*
|
|
473
|
+
* Call it from a suite's `beforeEach`, AFTER any `jest.clearAllMocks()`.
|
|
474
|
+
*/
|
|
475
|
+
export function mockWorkspaceExists(
|
|
476
|
+
client: WorkspaceMockHost,
|
|
477
|
+
...rows: readonly MockWorkspaceRow[]
|
|
478
|
+
): void {
|
|
479
|
+
const existing = new Map<string, string>();
|
|
480
|
+
for (const row of rows) {
|
|
481
|
+
if (typeof row === "string") {
|
|
482
|
+
// The sentinel workspace is the ONE row that hangs off the sentinel
|
|
483
|
+
// organization; everything else gets an ordinary one. See
|
|
484
|
+
// ORDINARY_ORGANIZATION_ID for why this distinction is load-bearing.
|
|
485
|
+
existing.set(
|
|
486
|
+
row,
|
|
487
|
+
row === SENTINEL_WORKSPACE_ID
|
|
488
|
+
? SENTINEL_ORGANIZATION_ID
|
|
489
|
+
: ORDINARY_ORGANIZATION_ID,
|
|
490
|
+
);
|
|
491
|
+
} else {
|
|
492
|
+
existing.set(row.id, row.organizationId);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
workspaceFindUnique(client).mockImplementation(
|
|
496
|
+
({ where }: { where: { id: string } }) =>
|
|
497
|
+
Promise.resolve(
|
|
498
|
+
existing.has(where.id)
|
|
499
|
+
? { id: where.id, organizationId: existing.get(where.id) }
|
|
500
|
+
: null,
|
|
501
|
+
),
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* The `"default"` site alias's resolution path (#6335): `currentSiteId()`
|
|
507
|
+
* returns `"default"`, `resolveWorkspaceIdForAlias` hands that to
|
|
508
|
+
* `resolveSentinelWorkspaceRow`, and only the well-known sentinel workspace
|
|
509
|
+
* — hanging off the sentinel Organization — satisfies it. Any further ids
|
|
510
|
+
* passed resolve beside it.
|
|
511
|
+
*/
|
|
512
|
+
export function mockSentinelWorkspaceExists(
|
|
513
|
+
client: WorkspaceMockHost,
|
|
514
|
+
...alsoExisting: readonly MockWorkspaceRow[]
|
|
515
|
+
): void {
|
|
516
|
+
mockWorkspaceExists(client, SENTINEL_WORKSPACE_ID, ...alsoExisting);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* The REJECTION path (#6335): no workspace resolves, whatever is asked for.
|
|
521
|
+
* Every suite that depends on an explicit `mockWorkspaceExists` carries at
|
|
522
|
+
* least one test using this, so that explicit setup is proven load-bearing
|
|
523
|
+
* rather than decorative — a route that quietly stopped resolving a tenancy
|
|
524
|
+
* scope would otherwise pass both ways.
|
|
525
|
+
*/
|
|
526
|
+
export function mockNoWorkspace(client: WorkspaceMockHost): void {
|
|
527
|
+
workspaceFindUnique(client).mockResolvedValue(null);
|
|
528
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file auth-email-budget.test.ts — the per-recipient hourly send budget (#5962 § D8c).
|
|
3
|
+
*
|
|
4
|
+
* Written before the implementation. The five properties that matter:
|
|
5
|
+
* - the bucket counts per (recipient, kind) and caps at three per hour;
|
|
6
|
+
* - the window rolls, so an hour later the recipient is clear again;
|
|
7
|
+
* - the store is bounded (MAX_BUCKETS eviction), because the key is a
|
|
8
|
+
* caller-supplied address and an attacker can mint unlimited distinct ones;
|
|
9
|
+
* - a recipient is keyed by a HASH, never by the plaintext address — this
|
|
10
|
+
* module is memory an operator can dump;
|
|
11
|
+
* - the site threshold is an operator SIGNAL, never a refusal. A site-wide
|
|
12
|
+
* cap an attacker can hold open is an indefinitely-holdable lockout
|
|
13
|
+
* (§ D8c, Q6), so nothing here may deny a send because of it.
|
|
14
|
+
*
|
|
15
|
+
* Time is injected rather than faked: the module reads `getClock().now()` by
|
|
16
|
+
* default and accepts an explicit `nowMs`, which is the seam this suite uses.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
consumeRecipientBudget,
|
|
21
|
+
siteVolumeAlert,
|
|
22
|
+
RECIPIENT_HOURLY_MAX,
|
|
23
|
+
SITE_HOURLY_ALERT_THRESHOLD,
|
|
24
|
+
__resetAuthEmailBudget,
|
|
25
|
+
__authEmailBucketCount,
|
|
26
|
+
__authEmailBudgetKeys,
|
|
27
|
+
} from "../auth-email-budget.js";
|
|
28
|
+
|
|
29
|
+
const T0 = 1_700_000_000_000;
|
|
30
|
+
const ONE_HOUR_MS = 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
__resetAuthEmailBudget();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("consumeRecipientBudget()", () => {
|
|
37
|
+
it("allows the first RECIPIENT_HOURLY_MAX sends and refuses the next", () => {
|
|
38
|
+
for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
|
|
39
|
+
expect(
|
|
40
|
+
consumeRecipientBudget("user@example.com", "password-reset", {
|
|
41
|
+
nowMs: T0,
|
|
42
|
+
}),
|
|
43
|
+
).toBe(true);
|
|
44
|
+
}
|
|
45
|
+
expect(
|
|
46
|
+
consumeRecipientBudget("user@example.com", "password-reset", {
|
|
47
|
+
nowMs: T0,
|
|
48
|
+
}),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("counts per (recipient, kind) — a reset flood cannot exhaust the notice", () => {
|
|
53
|
+
for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
|
|
54
|
+
consumeRecipientBudget("user@example.com", "password-reset", {
|
|
55
|
+
nowMs: T0,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
expect(
|
|
59
|
+
consumeRecipientBudget("user@example.com", "password-changed", {
|
|
60
|
+
nowMs: T0,
|
|
61
|
+
}),
|
|
62
|
+
).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("is case- and whitespace-insensitive on the recipient", () => {
|
|
66
|
+
for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
|
|
67
|
+
consumeRecipientBudget("User@Example.com", "password-reset", {
|
|
68
|
+
nowMs: T0,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
expect(
|
|
72
|
+
consumeRecipientBudget(" user@example.com ", "password-reset", {
|
|
73
|
+
nowMs: T0,
|
|
74
|
+
}),
|
|
75
|
+
).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("clears itself when the hour rolls", () => {
|
|
79
|
+
for (let i = 0; i < RECIPIENT_HOURLY_MAX; i++) {
|
|
80
|
+
consumeRecipientBudget("user@example.com", "password-reset", {
|
|
81
|
+
nowMs: T0,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
expect(
|
|
85
|
+
consumeRecipientBudget("user@example.com", "password-reset", {
|
|
86
|
+
nowMs: T0,
|
|
87
|
+
}),
|
|
88
|
+
).toBe(false);
|
|
89
|
+
expect(
|
|
90
|
+
consumeRecipientBudget("user@example.com", "password-reset", {
|
|
91
|
+
nowMs: T0 + ONE_HOUR_MS + 1,
|
|
92
|
+
}),
|
|
93
|
+
).toBe(true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("bounds the store — a fresh identity at capacity evicts the oldest", () => {
|
|
97
|
+
const cap = 50;
|
|
98
|
+
for (let i = 0; i < cap + 10; i++) {
|
|
99
|
+
consumeRecipientBudget(`user${i}@example.com`, "password-reset", {
|
|
100
|
+
nowMs: T0,
|
|
101
|
+
maxBuckets: cap,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
expect(__authEmailBucketCount()).toBeLessThanOrEqual(cap);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("never stores the plaintext recipient", () => {
|
|
108
|
+
consumeRecipientBudget("secret-person@example.com", "password-reset", {
|
|
109
|
+
nowMs: T0,
|
|
110
|
+
});
|
|
111
|
+
expect(__authEmailBudgetKeys().join("|")).not.toContain("secret-person");
|
|
112
|
+
expect(__authEmailBudgetKeys().join("|")).not.toContain("@example.com");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe("siteVolumeAlert()", () => {
|
|
117
|
+
it("does not fire below the threshold", () => {
|
|
118
|
+
for (let i = 0; i < SITE_HOURLY_ALERT_THRESHOLD - 1; i++) {
|
|
119
|
+
expect(siteVolumeAlert({ nowMs: T0 })).toBe(false);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("fires ONCE per hour past the threshold, and never refuses a send", () => {
|
|
124
|
+
let fired = 0;
|
|
125
|
+
for (let i = 0; i < SITE_HOURLY_ALERT_THRESHOLD + 25; i++) {
|
|
126
|
+
if (siteVolumeAlert({ nowMs: T0 })) fired++;
|
|
127
|
+
}
|
|
128
|
+
expect(fired).toBe(1);
|
|
129
|
+
|
|
130
|
+
// The signal is not a gate: a recipient under their own budget still sends.
|
|
131
|
+
expect(
|
|
132
|
+
consumeRecipientBudget("fresh@example.com", "password-reset", {
|
|
133
|
+
nowMs: T0,
|
|
134
|
+
}),
|
|
135
|
+
).toBe(true);
|
|
136
|
+
|
|
137
|
+
let firedNextHour = 0;
|
|
138
|
+
for (let i = 0; i < SITE_HOURLY_ALERT_THRESHOLD + 1; i++) {
|
|
139
|
+
if (siteVolumeAlert({ nowMs: T0 + ONE_HOUR_MS + 1 })) firedNextHour++;
|
|
140
|
+
}
|
|
141
|
+
expect(firedNextHour).toBe(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -5,11 +5,15 @@
|
|
|
5
5
|
* (a) CONTENT_ADAPTER unset → MDX adapter (existing behavior preserved)
|
|
6
6
|
* (b) CONTENT_ADAPTER=projection + valid manifest → projection adapter, read parity vs MDX
|
|
7
7
|
* (c) CONTENT_ADAPTER=projection + missing manifest → clear actionable error at construction
|
|
8
|
+
* (d) CONTENT_ADAPTER=database with no workspaceId → the resolver's named error
|
|
9
|
+
* (e) CONTENT_ADAPTER=database with a workspaceId → an adapter, not a throw (#5852)
|
|
8
10
|
*/
|
|
9
11
|
import { join, resolve } from "node:path";
|
|
10
12
|
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
11
13
|
import { tmpdir } from "node:os";
|
|
12
14
|
|
|
15
|
+
import { DEFAULT_WORKSPACE_ID } from "@working-theory/database/tenancy";
|
|
16
|
+
|
|
13
17
|
import {
|
|
14
18
|
getContentAdapter,
|
|
15
19
|
getContentRoot,
|
|
@@ -151,6 +155,25 @@ describe("getContentAdapter() factory", () => {
|
|
|
151
155
|
expect(() => getContentAdapter()).toThrow("pnpm content:compile");
|
|
152
156
|
});
|
|
153
157
|
});
|
|
158
|
+
|
|
159
|
+
describe("(d) CONTENT_ADAPTER=database → the resolver's workspaceId error", () => {
|
|
160
|
+
test("throws naming workspaceId, because apps/api does not resolve a tenant yet (#5323)", () => {
|
|
161
|
+
process.env.CONTENT_ADAPTER = "database";
|
|
162
|
+
|
|
163
|
+
expect(() => getContentAdapter()).toThrow(/workspaceId/);
|
|
164
|
+
expect(() => getContentAdapter()).toThrow(/CONTENT_ADAPTER=database/);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
describe("(e) CONTENT_ADAPTER=database + a workspaceId → an adapter (#5852)", () => {
|
|
169
|
+
test("builds the database adapter instead of throwing, once the caller resolves a tenant", () => {
|
|
170
|
+
process.env.CONTENT_ADAPTER = "database";
|
|
171
|
+
|
|
172
|
+
const adapter = getContentAdapter({ workspaceId: DEFAULT_WORKSPACE_ID });
|
|
173
|
+
expect(adapter).toBeDefined();
|
|
174
|
+
expect(typeof adapter.getPageBySlug).toBe("function");
|
|
175
|
+
});
|
|
176
|
+
});
|
|
154
177
|
});
|
|
155
178
|
|
|
156
179
|
describe("getContentRoot()", () => {
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file deliverable-asset-store.test.ts
|
|
3
|
+
* @description Unit tests for the deliverable-asset-store boundary (#6310
|
|
4
|
+
* D16) — the two scoped `Asset` reads a magnet deliverable resolution and
|
|
5
|
+
* its serve path need, modelled on `lib/asset-store.ts`'s own test shape.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// eslint-disable-next-line no-restricted-syntax -- prisma is a DB boundary; __mocks__/prisma.ts activates the auto-mock (Jest requires explicit call for local files)
|
|
9
|
+
jest.mock("../prisma.js");
|
|
10
|
+
|
|
11
|
+
import { prisma } from "../prisma.js";
|
|
12
|
+
import {
|
|
13
|
+
findDeliverableAssetIdByKey,
|
|
14
|
+
readDeliverableAsset,
|
|
15
|
+
} from "../deliverable-asset-store";
|
|
16
|
+
|
|
17
|
+
const WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000001";
|
|
18
|
+
const OTHER_WORKSPACE_ID = "wksp_00000000-0000-7000-8000-000000000002";
|
|
19
|
+
const ASSET_ID = "asset_00000000-0000-7000-8000-0000000000a1";
|
|
20
|
+
|
|
21
|
+
describe("findDeliverableAssetIdByKey", () => {
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
jest.clearAllMocks();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("queries with exactly { workspaceId, key }", async () => {
|
|
27
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({ id: ASSET_ID });
|
|
28
|
+
|
|
29
|
+
const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "q3-report.pdf");
|
|
30
|
+
|
|
31
|
+
expect(prisma.asset.findFirst).toHaveBeenCalledWith({
|
|
32
|
+
where: { workspaceId: WORKSPACE_ID, key: "q3-report.pdf" },
|
|
33
|
+
select: { id: true },
|
|
34
|
+
});
|
|
35
|
+
expect(result).toBe(ASSET_ID);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not return a same-key row scoped to a different workspace", async () => {
|
|
39
|
+
// The mock proves the CALL is scoped correctly; a real DB enforces the
|
|
40
|
+
// rest. This asserts the where-clause never widens to a foreign
|
|
41
|
+
// workspace even when one exists.
|
|
42
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
|
|
43
|
+
|
|
44
|
+
const result = await findDeliverableAssetIdByKey(
|
|
45
|
+
OTHER_WORKSPACE_ID,
|
|
46
|
+
"q3-report.pdf",
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
expect(prisma.asset.findFirst).toHaveBeenCalledWith({
|
|
50
|
+
where: { workspaceId: OTHER_WORKSPACE_ID, key: "q3-report.pdf" },
|
|
51
|
+
select: { id: true },
|
|
52
|
+
});
|
|
53
|
+
expect(result).toBeNull();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns null on a miss", async () => {
|
|
57
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
|
|
58
|
+
|
|
59
|
+
const result = await findDeliverableAssetIdByKey(WORKSPACE_ID, "no-such-key");
|
|
60
|
+
|
|
61
|
+
expect(result).toBeNull();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
describe("readDeliverableAsset", () => {
|
|
66
|
+
beforeEach(() => {
|
|
67
|
+
jest.clearAllMocks();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("queries with exactly { id, workspaceId } and selects filename/contentType/bytes", async () => {
|
|
71
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({
|
|
72
|
+
filename: "q3-report.pdf",
|
|
73
|
+
contentType: "application/pdf",
|
|
74
|
+
bytes: Buffer.from("PDF-BYTES"),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
|
|
78
|
+
|
|
79
|
+
expect(prisma.asset.findFirst).toHaveBeenCalledWith({
|
|
80
|
+
where: { id: ASSET_ID, workspaceId: WORKSPACE_ID },
|
|
81
|
+
select: { filename: true, contentType: true, bytes: true },
|
|
82
|
+
});
|
|
83
|
+
expect(result).toEqual({
|
|
84
|
+
filename: "q3-report.pdf",
|
|
85
|
+
contentType: "application/pdf",
|
|
86
|
+
bytes: expect.any(Buffer),
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns null for a foreign-workspace id (the scoped read excludes it)", async () => {
|
|
91
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
|
|
92
|
+
|
|
93
|
+
const result = await readDeliverableAsset(ASSET_ID, OTHER_WORKSPACE_ID);
|
|
94
|
+
|
|
95
|
+
expect(result).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("wraps the returned bytes as a Buffer view, not a copy", async () => {
|
|
99
|
+
const raw = new Uint8Array([1, 2, 3, 4]);
|
|
100
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue({
|
|
101
|
+
filename: "f.pdf",
|
|
102
|
+
contentType: "application/pdf",
|
|
103
|
+
bytes: raw,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
const result = await readDeliverableAsset(ASSET_ID, WORKSPACE_ID);
|
|
107
|
+
|
|
108
|
+
expect(Buffer.isBuffer(result?.bytes)).toBe(true);
|
|
109
|
+
expect(result?.bytes.byteOffset).toBe(raw.byteOffset);
|
|
110
|
+
expect(result?.bytes.buffer).toBe(raw.buffer);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("module boundary contract (§1.14 / D16)", () => {
|
|
115
|
+
it("exports no write, no findMany and no unscoped read", () => {
|
|
116
|
+
// The boundary's entire contract is "two named functions, nothing else
|
|
117
|
+
// reads prisma.asset" — asserted at the module surface so a future edit
|
|
118
|
+
// adding a third export or a raw prisma re-export fails this test.
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires -- asserting the module's own export surface
|
|
120
|
+
const mod = require("../deliverable-asset-store");
|
|
121
|
+
expect(Object.keys(mod).sort()).toEqual(
|
|
122
|
+
["findDeliverableAssetIdByKey", "readDeliverableAsset"].sort(),
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -97,4 +97,24 @@ describe("error-serializer", () => {
|
|
|
97
97
|
expect(body["message"]).toBeUndefined();
|
|
98
98
|
expect(body["statusCode"]).toBeUndefined();
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
// #6019 AC-2 — `pathWithoutQuery` (#5970 security review, finding 1): a
|
|
102
|
+
// route may carry its credential in the query string
|
|
103
|
+
// (`/v1/requests/:id/confirm?token=…` does), and this handler logs `path`
|
|
104
|
+
// on EVERY 5xx. A future refactor restoring the raw `request.url` must turn
|
|
105
|
+
// this red.
|
|
106
|
+
it("scrubs a query-string credential from the logged path on a 5xx", async () => {
|
|
107
|
+
await server.inject({
|
|
108
|
+
method: "GET",
|
|
109
|
+
url: "/boom?token=SUPERSECRETTOKEN",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const logged = logLines.find(
|
|
113
|
+
(line) => line["msg"] === "internal server error",
|
|
114
|
+
);
|
|
115
|
+
expect(logged?.["path"]).toBe("/boom");
|
|
116
|
+
const text = JSON.stringify(logLines);
|
|
117
|
+
expect(text).not.toContain("SUPERSECRETTOKEN");
|
|
118
|
+
expect(text).not.toContain("token=");
|
|
119
|
+
});
|
|
100
120
|
});
|