create-ailk 0.22.0 → 0.23.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/component-catalog.json +149 -5
- package/component-catalog.md +7 -6
- package/dist/add-module.js +11 -9
- package/dist/add-route.d.ts +55 -1
- package/dist/add-route.js +294 -12
- package/dist/cli.js +27 -10
- package/dist/derive-architecture.d.ts +7 -5
- package/dist/derive-architecture.js +9 -32
- package/dist/lib/design-checks.js +4 -2
- package/dist/lib/env-catalog.js +0 -4
- package/dist/lib/npm-auth.d.ts +9 -0
- package/dist/lib/npm-auth.js +48 -0
- package/dist/lib/scaffold-render-sweep.d.ts +3 -2
- package/dist/lib/scaffold-render-sweep.js +5 -8
- package/dist/module-architecture.js +98 -10
- package/dist/paid-sections.d.ts +21 -2
- package/dist/paid-sections.js +69 -7
- package/dist/seed-flows.d.ts +4 -1
- package/dist/seed-flows.js +50 -4
- package/dist/spoke-checkout.d.ts +38 -35
- package/dist/spoke-checkout.js +66 -229
- package/dist/starter-grid.d.ts +0 -3
- package/dist/starter-grid.js +24 -14
- package/dist/surfaces.js +33 -12
- package/dist/sync-routes.d.ts +38 -1
- package/dist/sync-routes.js +184 -11
- package/dist/template-record.d.ts +1 -1
- package/dist/template-record.js +3 -2
- package/dist/update.js +10 -0
- package/package.json +3 -3
- package/site-templates/_base/content/en/NewsletterSignup/newsletter.mdx +4 -49
- package/site-templates/_base/content/en/Pricing/pricing.mdx +3 -21
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +23 -20
- package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +28 -14
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +15 -39
- package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +38 -34
- package/site-templates/marketing-site-free/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/local-business-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/organization-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +14 -31
- package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +26 -25
- package/site-templates/shapes/person-service/content/en/NewsletterSignup/newsletter.mdx +6 -43
- package/templates/.env.example +9 -7
- package/templates/.github/workflows/ci.yml +13 -0
- package/templates/README.md +1 -0
- package/templates/_gitignore +4 -0
- package/templates/apps/api/.env.example +27 -0
- package/templates/apps/api/CLAUDE.md +77 -16
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +2 -0
- package/templates/apps/api/src/bin/stripe-connections-rekey.ts +45 -0
- package/templates/apps/api/src/config/__tests__/stripe-connection-key.test.ts +185 -0
- package/templates/apps/api/src/config/index.ts +109 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +3 -0
- package/templates/apps/api/src/lib/__tests__/flow-rename-race.db.test.ts +116 -0
- package/templates/apps/api/src/lib/error-serializer.ts +74 -7
- package/templates/apps/api/src/lib/stripe.ts +74 -4
- package/templates/apps/api/src/lib/webhook-idempotency.ts +66 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +218 -0
- package/templates/apps/api/src/middleware/__tests__/write-budget.test.ts +174 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +226 -4
- package/templates/apps/api/src/openapi/spec.ts +10 -7
- package/templates/apps/api/src/routes/billing/plan-checkout.ts +325 -0
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/offerings-authorization.test.ts +1300 -0
- package/templates/apps/api/src/routes/checkout/__tests__/session.test.ts +11 -0
- package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +49 -0
- package/templates/apps/api/src/routes/checkout/session.ts +57 -25
- package/templates/apps/api/src/routes/checkout/sessions.ts +232 -82
- package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +5 -5
- package/templates/apps/api/src/routes/consent/__tests__/sign-up-rate-limit.test.ts +160 -0
- package/templates/apps/api/src/routes/consent/account.ts +4 -2
- package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +2 -2
- package/templates/apps/api/src/routes/content/__tests__/offerings.test.ts +731 -0
- package/templates/apps/api/src/routes/content/__tests__/pricing.test.ts +753 -0
- package/templates/apps/api/src/routes/content/__tests__/shown-offerings.test.ts +762 -0
- package/templates/apps/api/src/routes/content/index.ts +16 -2
- package/templates/apps/api/src/routes/content/offerings.ts +184 -0
- package/templates/apps/api/src/routes/content/pricing.ts +176 -0
- package/templates/apps/api/src/routes/content/shown-offerings.ts +229 -0
- package/templates/apps/api/src/routes/leads/index.ts +17 -14
- package/templates/apps/api/src/routes/webhooks/README.md +66 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-offerings.test.ts +814 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-org-billing.test.ts +80 -68
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +5 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe.test.ts +8 -2
- package/templates/apps/api/src/routes/webhooks/index.ts +23 -0
- package/templates/apps/api/src/routes/webhooks/stripe-offerings.ts +384 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +91 -17
- package/templates/apps/api/src/server.ts +140 -51
- package/templates/apps/api/src/services/__tests__/checkout-plan.test.ts +313 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +51 -2
- package/templates/apps/api/src/services/__tests__/keyless-workspace-predicate.test.ts +234 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +374 -44
- package/templates/apps/api/src/services/__tests__/person.test.ts +232 -1
- package/templates/apps/api/src/services/__tests__/plan-offerings.test.ts +444 -0
- package/templates/apps/api/src/services/deliverable-fulfillment.ts +13 -5
- package/templates/apps/api/src/services/entitlements.ts +157 -1
- package/templates/apps/api/src/services/lead-routing.ts +113 -58
- package/templates/apps/api/src/services/offerings/__tests__/catalog.test.ts +295 -0
- package/templates/apps/api/src/services/offerings/__tests__/checkout-session-record.test.ts +268 -0
- package/templates/apps/api/src/services/offerings/__tests__/connection.test.ts +868 -0
- package/templates/apps/api/src/services/offerings/__tests__/fixtures.ts +416 -0
- package/templates/apps/api/src/services/offerings/__tests__/org-products.test.ts +396 -0
- package/templates/apps/api/src/services/offerings/__tests__/readers.test.ts +681 -0
- package/templates/apps/api/src/services/offerings/__tests__/source.test.ts +231 -0
- package/templates/apps/api/src/services/offerings/catalog.ts +308 -0
- package/templates/apps/api/src/services/offerings/checkout-authorization.ts +385 -0
- package/templates/apps/api/src/services/offerings/checkout-session-record.ts +192 -0
- package/templates/apps/api/src/services/offerings/connection.ts +1003 -0
- package/templates/apps/api/src/services/offerings/index.ts +614 -0
- package/templates/apps/api/src/services/offerings/source.ts +140 -0
- package/templates/apps/api/src/services/offerings/store.ts +150 -0
- package/templates/apps/api/src/services/person.ts +124 -9
- package/templates/apps/api/src/services/plan-offerings.ts +96 -30
- package/templates/apps/api/src/services/public-site-resolver.ts +19 -14
- package/templates/apps/mcp/CLAUDE.md +22 -14
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +9 -4
- package/templates/apps/mcp/__tests__/get_offerings.test.ts +358 -0
- package/templates/apps/mcp/__tests__/get_pricing.test.ts +364 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +4 -4
- package/templates/apps/mcp/__tests__/module-exclusion.test.ts +2 -2
- package/templates/apps/mcp/__tests__/server.test.ts +13 -11
- package/templates/apps/mcp/__tests__/start_purchase.test.ts +208 -12
- package/templates/apps/mcp/__tests__/tools/purchase.test.ts +11 -11
- package/templates/apps/mcp/src/lib/shown-offerings.ts +105 -0
- package/templates/apps/mcp/src/server.ts +5 -0
- package/templates/apps/mcp/src/tools/get_offerings.ts +47 -30
- package/templates/apps/mcp/src/tools/get_pricing.ts +40 -40
- package/templates/apps/mcp/src/tools/purchase.ts +30 -2
- package/templates/apps/mcp/src/workspace-server.ts +15 -0
- package/templates/apps/web/.env.example +10 -31
- package/templates/apps/web/CLAUDE.md +315 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +3 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/[action]/page.tsx +189 -0
- package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +4 -2
- package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +8 -3
- package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +22 -144
- package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +9 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-hub.test.tsx +172 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/careers-set.test.tsx +149 -0
- package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +22 -24
- package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +140 -66
- package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +57 -22
- package/templates/apps/web/app/[locale]/careers/page.tsx +129 -5
- package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +119 -11
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +28 -5
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +6 -2
- package/templates/apps/web/app/[locale]/contact/page.tsx +99 -92
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +237 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.count.test.tsx +43 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.deliverable.test.tsx +200 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.ending.test.tsx +197 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/magnet-page.test.tsx +430 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/two-route-tool.test.tsx +21 -9
- package/templates/apps/web/app/[locale]/flows/[slug]/magnet-page.tsx +352 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/page.tsx +12 -73
- package/templates/apps/web/app/[locale]/flows/[slug]/start/page.tsx +10 -31
- package/templates/apps/web/app/[locale]/layout.tsx +55 -4
- package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +174 -104
- package/templates/apps/web/app/[locale]/newsletter/page.tsx +95 -58
- package/templates/apps/web/app/[locale]/pricing/__tests__/__snapshots__/unconnected.test.tsx.snap +7 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/offerings.test.tsx +599 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/page.test.tsx +216 -0
- package/templates/apps/web/app/[locale]/pricing/__tests__/unconnected.test.tsx +236 -0
- package/templates/apps/web/app/[locale]/pricing/page.tsx +367 -6
- package/templates/apps/web/app/[locale]/team/__tests__/team-routes.test.tsx +4 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +17 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +62 -3
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +70 -18
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +30 -9
- package/templates/apps/web/app/api/offerings/checkout/[lookupKey]/route.ts +316 -0
- package/templates/apps/web/app/api/offerings/checkout/__tests__/route.test.ts +810 -0
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +27 -1
- package/templates/apps/web/lib/__tests__/deck-page.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/in-page-magnet.test.ts +265 -0
- package/templates/apps/web/lib/__tests__/magnet-routes.test.ts +57 -0
- package/templates/apps/web/lib/__tests__/newsletter.actions.test.ts +293 -0
- package/templates/apps/web/lib/__tests__/offerings-pricing.test.ts +601 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +67 -8
- package/templates/apps/web/lib/auth-routes.ts +36 -2
- package/templates/apps/web/lib/checkout-config.ts +9 -20
- package/templates/apps/web/lib/deck-page.ts +1 -0
- package/templates/apps/web/lib/deliverable-grant.ts +18 -0
- package/templates/apps/web/lib/in-page-magnet.ts +416 -0
- package/templates/apps/web/lib/magnet-routes.ts +42 -0
- package/templates/apps/web/lib/modules.generated.ts +9 -0
- package/templates/apps/web/lib/newsletter.actions.ts +202 -0
- package/templates/apps/web/lib/offerings-pricing.ts +326 -0
- package/templates/apps/web/lib/posting-title.ts +17 -0
- package/templates/apps/web/lib/sign-up-agreements.ts +4 -2
- package/templates/apps/web/messages/en.json +49 -15
- package/templates/apps/web/middleware.ts +12 -2
- package/templates/apps/web/security-headers.config.ts +17 -0
- package/templates/content/en/BlogList/blog.mdx +4 -71
- package/templates/content/en/BookCall/book-a-call.mdx +1 -41
- package/templates/content/en/BookCall/onsite-estimate.mdx +1 -17
- package/templates/content/en/BookCall/onsite-service.mdx +1 -17
- package/templates/content/en/BookCall/start-project.mdx +1 -17
- package/templates/content/en/ContactPage/contact.mdx +6 -63
- package/templates/content/en/HomePage/home.mdx +3 -21
- package/templates/database/CHANGELOG.md +31 -0
- package/templates/database/__tests__/in-page-magnets-migration.test.ts +161 -0
- package/templates/database/__tests__/offerings-checkout-session.db.test.ts +270 -0
- package/templates/database/__tests__/offerings-migration.test.ts +197 -0
- package/templates/database/__tests__/offerings-shown-price-ids-migration.test.ts +86 -0
- package/templates/database/__tests__/offerings.db.test.ts +303 -0
- package/templates/database/__tests__/seed.test.ts +31 -1
- package/templates/database/auth/schema.prisma +4 -0
- package/templates/database/config/schema.prisma +91 -0
- package/templates/database/migrations/20261020120000_offerings_data_model/migration.sql +146 -0
- package/templates/database/migrations/20261021120000_offerings_checkout_session/migration.sql +77 -0
- package/templates/database/migrations/20261022120000_in_page_magnets/migration.sql +45 -0
- package/templates/database/migrations/20261023120000_offerings_shown_price_ids/migration.sql +47 -0
- package/templates/database/migrations/20261024120000_organization_plan_checkout_session/migration.sql +16 -0
- package/templates/database/package.json +1 -1
- package/templates/database/seed.ts +30 -2
- package/versions.json +35 -35
- package/site-templates/marketing-site-free/content/en/Pricing/pricing.mdx +0 -199
- package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +0 -93
- package/templates/apps/web/app/[locale]/downloads/page.tsx +0 -37
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +0 -139
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +0 -93
- package/templates/apps/web/app/api/checkout/[offering]/route.ts +0 -48
- package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +0 -120
- package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +0 -86
- package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +0 -92
- package/templates/apps/web/app/api/webhooks/stripe/route.ts +0 -31
- package/templates/apps/web/lib/__tests__/checkout.test.ts +0 -243
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +0 -120
- package/templates/apps/web/lib/checkout.ts +0 -93
- package/templates/apps/web/lib/meeting-slugs.ts +0 -63
|
@@ -471,6 +471,55 @@ describe("fulfillDeliverable", () => {
|
|
|
471
471
|
);
|
|
472
472
|
});
|
|
473
473
|
|
|
474
|
+
// ── #8848 D3 — the minted grant id, and a deliverable flow's Asset-only store ──
|
|
475
|
+
|
|
476
|
+
it("resolves { grantId } equal to the created row's id on a successful mint", async () => {
|
|
477
|
+
await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it("resolves undefined on the asset_missing path", async () => {
|
|
481
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
|
|
482
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
|
|
483
|
+
(prisma.deliverableAsset.findUnique as jest.Mock).mockResolvedValue(null);
|
|
484
|
+
|
|
485
|
+
await expect(fulfillDeliverable(baseInput())).resolves.toBeUndefined();
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("a gated file snapshot whose key names only a legacy DeliverableAsset mints no grant (SEC-8848-B)", async () => {
|
|
489
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
|
|
490
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
|
|
491
|
+
const configSnapshot = {
|
|
492
|
+
...baseConfigSnapshot(),
|
|
493
|
+
deliverables: { ...deliverablesBlock(), assets: [{ assetKey: "listing-acme-2026-10-12" }] },
|
|
494
|
+
deliverable: { kind: "file", gated: true, title: en("Q3"), geometry: "letter", pages: 3 },
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
await expect(
|
|
498
|
+
fulfillDeliverable(baseInput({ segment: undefined, configSnapshot })),
|
|
499
|
+
).resolves.toBeUndefined();
|
|
500
|
+
|
|
501
|
+
expect(prisma.deliverableAsset.findUnique).not.toHaveBeenCalled();
|
|
502
|
+
expect(prisma.deliverableGrant.create).not.toHaveBeenCalled();
|
|
503
|
+
expect(mockLoggerInfo).toHaveBeenCalledWith(
|
|
504
|
+
expect.objectContaining({ outcome: "asset_missing" }),
|
|
505
|
+
expect.any(String),
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("the same legacy-only key on a flow with no deliverable block still mints (NFR1)", async () => {
|
|
510
|
+
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({ workspaceId: WORKSPACE_ID });
|
|
511
|
+
(prisma.asset.findFirst as jest.Mock).mockResolvedValue(null);
|
|
512
|
+
const configSnapshot = {
|
|
513
|
+
...baseConfigSnapshot(),
|
|
514
|
+
deliverables: { ...deliverablesBlock(), assets: [{ assetKey: "listing-acme-2026-10-12" }] },
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
await expect(
|
|
518
|
+
fulfillDeliverable(baseInput({ segment: undefined, configSnapshot })),
|
|
519
|
+
).resolves.toEqual({ grantId: GRANT_ID });
|
|
520
|
+
expect(prisma.deliverableAsset.findUnique).toHaveBeenCalled();
|
|
521
|
+
});
|
|
522
|
+
|
|
474
523
|
it("never reaches a key that exists only in ANOTHER workspace (NFR3) — the lookup is scoped to this flow's own workspaceId alone", async () => {
|
|
475
524
|
(prisma.flowDefinition.findUnique as jest.Mock).mockResolvedValue({
|
|
476
525
|
workspaceId: WORKSPACE_ID,
|
|
@@ -692,7 +741,7 @@ describe("fulfillDeliverable", () => {
|
|
|
692
741
|
error: { kind: "send_failed", message: "Resend down" },
|
|
693
742
|
});
|
|
694
743
|
|
|
695
|
-
await expect(fulfillDeliverable(baseInput())).resolves.
|
|
744
|
+
await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
|
|
696
745
|
expect(mockLoggerInfo).toHaveBeenCalled();
|
|
697
746
|
// AC4: send_failed is a post-mint outcome — it stamps too.
|
|
698
747
|
expect(prisma.deliverableGrant.update).toHaveBeenCalledWith({
|
|
@@ -708,7 +757,7 @@ describe("fulfillDeliverable", () => {
|
|
|
708
757
|
new Error("connection reset"),
|
|
709
758
|
);
|
|
710
759
|
|
|
711
|
-
await expect(fulfillDeliverable(baseInput())).resolves.
|
|
760
|
+
await expect(fulfillDeliverable(baseInput())).resolves.toEqual({ grantId: GRANT_ID });
|
|
712
761
|
expect(mockSendDeliverableEmail).toHaveBeenCalled();
|
|
713
762
|
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
|
714
763
|
expect.objectContaining({ grantId: GRANT_ID, outcome: "sent" }),
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file keyless-workspace-predicate.test.ts
|
|
3
|
+
*
|
|
4
|
+
* One keyless-workspace predicate (#9145). `resolvePublicWorkspace` (content,
|
|
5
|
+
* pricing, schedule, the consent cookie) and `resolveCallerWorkspace` (the
|
|
6
|
+
* `/v1/flows` routes and the open deliverable) answer a keyless caller through
|
|
7
|
+
* the SAME rule — `services/default-tenant.ts#resolveDefaultWorkspaceId`: one
|
|
8
|
+
* workspace that is the seeded default resolves to it, anything else is
|
|
9
|
+
* unresolved. The `AILK_SITE_ID` alias is never consulted for a keyless
|
|
10
|
+
* caller, so a multi-workspace deployment that is not a hub answers every
|
|
11
|
+
* anonymous route the same way instead of 503 on `/pricing` and the alias
|
|
12
|
+
* workspace on `/flows/*`.
|
|
13
|
+
*
|
|
14
|
+
* The spoke-key arm and every refusal are unchanged; the hub cases here pin
|
|
15
|
+
* that a keyless call on a hub is refused by both resolvers alike.
|
|
16
|
+
*
|
|
17
|
+
* External dependency boundaries mocked:
|
|
18
|
+
* - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
|
|
19
|
+
*/
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
21
|
+
|
|
22
|
+
// 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.
|
|
23
|
+
jest.mock("../../lib/prisma.js");
|
|
24
|
+
|
|
25
|
+
import { DEFAULT_WORKSPACE_ID } from "@working-theory/database/tenancy";
|
|
26
|
+
import type { FastifyRequest } from "fastify";
|
|
27
|
+
|
|
28
|
+
import * as configModule from "../../config/index.js";
|
|
29
|
+
import type { HubSpokeKeyMode } from "../../config/index.js";
|
|
30
|
+
import { __resetSiteWorkspaceCacheForTests } from "../../lib/site.js";
|
|
31
|
+
import {
|
|
32
|
+
mockSentinelWorkspaceExists,
|
|
33
|
+
mockWorkspaceExists,
|
|
34
|
+
} from "../../lib/__mocks__/prisma.js";
|
|
35
|
+
import { prisma } from "../../lib/prisma.js";
|
|
36
|
+
import {
|
|
37
|
+
__resetHubStatusForTests,
|
|
38
|
+
resolveCallerWorkspace,
|
|
39
|
+
resolvePublicWorkspace,
|
|
40
|
+
} from "../public-site-resolver.js";
|
|
41
|
+
|
|
42
|
+
const db = jest.mocked(prisma);
|
|
43
|
+
|
|
44
|
+
const W1 = "wksp_01920000-0000-7000-8000-0000000000b1";
|
|
45
|
+
const W2 = "wksp_01920000-0000-7000-8000-0000000000b2";
|
|
46
|
+
const S1 = "spoke_01920000-0000-7000-8000-0000000000c1";
|
|
47
|
+
const KEY_S1 = `ailk_spk_S1${"x".repeat(41)}`;
|
|
48
|
+
|
|
49
|
+
const digest = (key: string) => createHash("sha256").update(key).digest("hex");
|
|
50
|
+
|
|
51
|
+
/** A request double carrying only what the resolver may read. */
|
|
52
|
+
function requestWith(headers: Record<string, string> = {}): FastifyRequest {
|
|
53
|
+
// eslint-disable-next-line no-restricted-syntax -- a FastifyRequest double carrying only the two members the resolver reads (headers, log)
|
|
54
|
+
return {
|
|
55
|
+
headers,
|
|
56
|
+
log: { warn: jest.fn(), info: jest.fn(), error: jest.fn() },
|
|
57
|
+
} as unknown as FastifyRequest;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function setMode(mode: HubSpokeKeyMode): void {
|
|
61
|
+
jest.replaceProperty(configModule, "config", {
|
|
62
|
+
...configModule.config,
|
|
63
|
+
AILK_HUB_REQUIRE_SPOKE_KEY: mode,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A deployment with no spoke registered: not a hub (#7633 D1). */
|
|
68
|
+
function notAHub(): void {
|
|
69
|
+
db.spoke.findFirst.mockResolvedValue(null);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A hub: its registry holds `S1` → `W1`. */
|
|
73
|
+
function aHub(): void {
|
|
74
|
+
db.spoke.findFirst.mockResolvedValue({ id: S1 } as never);
|
|
75
|
+
db.spoke.findUnique.mockImplementation((async ({
|
|
76
|
+
where,
|
|
77
|
+
}: {
|
|
78
|
+
where: { credentialHash: string };
|
|
79
|
+
}) =>
|
|
80
|
+
where.credentialHash === digest(KEY_S1)
|
|
81
|
+
? { id: S1, workspaceId: W1, revokedAt: null }
|
|
82
|
+
: null) as never);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Exactly one workspace, the seeded default on the sentinel Organization. */
|
|
86
|
+
function oneWorkspaceDeployment(): void {
|
|
87
|
+
db.workspace.count.mockResolvedValue(1);
|
|
88
|
+
mockSentinelWorkspaceExists(db);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Three workspaces — the seeded default beside `W1` and `W2`. */
|
|
92
|
+
function multiWorkspaceDeployment(): void {
|
|
93
|
+
db.workspace.count.mockResolvedValue(3);
|
|
94
|
+
mockSentinelWorkspaceExists(db, W1, W2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const saved = {
|
|
98
|
+
adapter: process.env.CONTENT_ADAPTER,
|
|
99
|
+
databaseUrl: process.env.DATABASE_URL,
|
|
100
|
+
siteId: process.env.AILK_SITE_ID,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function restore(name: string, value: string | undefined): void {
|
|
104
|
+
if (value === undefined) delete process.env[name];
|
|
105
|
+
else process.env[name] = value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
beforeEach(() => {
|
|
109
|
+
jest.resetAllMocks();
|
|
110
|
+
process.env.DATABASE_URL = "postgres://stub";
|
|
111
|
+
process.env.CONTENT_ADAPTER = "database";
|
|
112
|
+
delete process.env.AILK_SITE_ID;
|
|
113
|
+
setMode("auto");
|
|
114
|
+
__resetHubStatusForTests();
|
|
115
|
+
__resetSiteWorkspaceCacheForTests();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
jest.restoreAllMocks();
|
|
120
|
+
restore("CONTENT_ADAPTER", saved.adapter);
|
|
121
|
+
restore("DATABASE_URL", saved.databaseUrl);
|
|
122
|
+
restore("AILK_SITE_ID", saved.siteId);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("a one-workspace deployment that is not a hub (#9145)", () => {
|
|
126
|
+
beforeEach(() => {
|
|
127
|
+
notAHub();
|
|
128
|
+
oneWorkspaceDeployment();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("serves the same workspace through both resolvers", async () => {
|
|
132
|
+
const pub = await resolvePublicWorkspace(requestWith());
|
|
133
|
+
const caller = await resolveCallerWorkspace(requestWith());
|
|
134
|
+
|
|
135
|
+
expect(pub).toEqual({ kind: "resolved", workspaceId: DEFAULT_WORKSPACE_ID });
|
|
136
|
+
expect(caller).toEqual({
|
|
137
|
+
kind: "resolved",
|
|
138
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
139
|
+
viaSpoke: false,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("resolveCallerWorkspace answers the predicate on any content adapter — flows are database rows", async () => {
|
|
144
|
+
process.env.CONTENT_ADAPTER = "mdx";
|
|
145
|
+
|
|
146
|
+
await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
|
|
147
|
+
kind: "resolved",
|
|
148
|
+
workspaceId: DEFAULT_WORKSPACE_ID,
|
|
149
|
+
viaSpoke: false,
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("a multi-workspace deployment that is not a hub (#9145)", () => {
|
|
155
|
+
beforeEach(() => {
|
|
156
|
+
notAHub();
|
|
157
|
+
multiWorkspaceDeployment();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("answers unresolved through both resolvers, though the default alias resolves", async () => {
|
|
161
|
+
const pub = await resolvePublicWorkspace(requestWith());
|
|
162
|
+
const caller = await resolveCallerWorkspace(requestWith());
|
|
163
|
+
|
|
164
|
+
expect(pub).toEqual({ kind: "unresolved", reason: "ambiguous" });
|
|
165
|
+
expect(caller).toEqual({ kind: "unresolved", reason: "ambiguous" });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("answers unresolved through both, though AILK_SITE_ID names a real workspace", async () => {
|
|
169
|
+
process.env.AILK_SITE_ID = W1;
|
|
170
|
+
|
|
171
|
+
await expect(resolvePublicWorkspace(requestWith())).resolves.toEqual({
|
|
172
|
+
kind: "unresolved",
|
|
173
|
+
reason: "ambiguous",
|
|
174
|
+
});
|
|
175
|
+
await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
|
|
176
|
+
kind: "unresolved",
|
|
177
|
+
reason: "ambiguous",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("a sole workspace that is not the seeded default (#9145)", () => {
|
|
183
|
+
it("answers unresolved through both, though AILK_SITE_ID names it", async () => {
|
|
184
|
+
notAHub();
|
|
185
|
+
process.env.AILK_SITE_ID = W1;
|
|
186
|
+
db.workspace.count.mockResolvedValue(1);
|
|
187
|
+
mockWorkspaceExists(db, W1);
|
|
188
|
+
|
|
189
|
+
await expect(resolvePublicWorkspace(requestWith())).resolves.toEqual({
|
|
190
|
+
kind: "unresolved",
|
|
191
|
+
reason: "ambiguous",
|
|
192
|
+
});
|
|
193
|
+
await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
|
|
194
|
+
kind: "unresolved",
|
|
195
|
+
reason: "ambiguous",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("a hub (#9145 — the refusals are unchanged)", () => {
|
|
201
|
+
it("AILK_HUB_REQUIRE_SPOKE_KEY on refuses a keyless call through both, on a one-workspace deployment", async () => {
|
|
202
|
+
setMode("on");
|
|
203
|
+
notAHub();
|
|
204
|
+
oneWorkspaceDeployment();
|
|
205
|
+
|
|
206
|
+
await expect(resolvePublicWorkspace(requestWith())).resolves.toEqual({
|
|
207
|
+
kind: "unresolved",
|
|
208
|
+
reason: "spoke-refused",
|
|
209
|
+
});
|
|
210
|
+
await expect(resolveCallerWorkspace(requestWith())).resolves.toEqual({
|
|
211
|
+
kind: "unresolved",
|
|
212
|
+
reason: "spoke-refused",
|
|
213
|
+
});
|
|
214
|
+
expect(db.workspace.count).not.toHaveBeenCalled();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("a registered spoke still resolves to its own workspace through both", async () => {
|
|
218
|
+
aHub();
|
|
219
|
+
multiWorkspaceDeployment();
|
|
220
|
+
const headers = { "x-ailk-spoke-key": KEY_S1 };
|
|
221
|
+
|
|
222
|
+
await expect(resolvePublicWorkspace(requestWith(headers))).resolves.toEqual({
|
|
223
|
+
kind: "resolved",
|
|
224
|
+
workspaceId: W1,
|
|
225
|
+
});
|
|
226
|
+
await expect(resolveCallerWorkspace(requestWith(headers))).resolves.toEqual({
|
|
227
|
+
kind: "resolved",
|
|
228
|
+
workspaceId: W1,
|
|
229
|
+
viaSpoke: true,
|
|
230
|
+
spokeId: S1,
|
|
231
|
+
});
|
|
232
|
+
expect(db.workspace.count).not.toHaveBeenCalled();
|
|
233
|
+
});
|
|
234
|
+
});
|