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
|
@@ -1,243 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment node
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The standard spoke checkout (#8748 D6; spec
|
|
7
|
-
* docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2, AC-3, AC-4).
|
|
8
|
-
*
|
|
9
|
-
* `./checkout-config` — the site's own data — is mocked with a fixture in
|
|
10
|
-
* every case, so this suite passes against any shape of the site's file,
|
|
11
|
-
* including a pre-#8748 one that also carries `siteBaseUrl` and `fulfil`.
|
|
12
|
-
* The hub's `leads.submit` is the one external boundary mocked: the
|
|
13
|
-
* fulfilment must post one lead marked with the site, under the session id as
|
|
14
|
-
* its Idempotency-Key, with a body that stays byte-identical between attempts
|
|
15
|
-
* so the hub can replay it.
|
|
16
|
-
*/
|
|
17
|
-
import { readFileSync } from "node:fs";
|
|
18
|
-
import { join } from "node:path";
|
|
19
|
-
|
|
20
|
-
const mockSubmit = jest.fn();
|
|
21
|
-
jest.mock("@working-theory/api-client", () => ({
|
|
22
|
-
__esModule: true,
|
|
23
|
-
leads: { submit: (...args: unknown[]) => mockSubmit(...args) },
|
|
24
|
-
}));
|
|
25
|
-
|
|
26
|
-
const OFFERINGS = {
|
|
27
|
-
starter: { kind: "fixed", lookupKey: "acme_starter", cancelPath: "/" },
|
|
28
|
-
};
|
|
29
|
-
const mockSite: { value: Record<string, unknown> } = {
|
|
30
|
-
value: { site: "acme", offerings: OFFERINGS },
|
|
31
|
-
};
|
|
32
|
-
// eslint-disable-next-line no-restricted-syntax -- #507: the site's checkout-config.ts is the site's own data, not this suite's subject; a fixture stands in for it so the suite holds against any shape a site's file has (#8748 AC-2).
|
|
33
|
-
jest.mock("../checkout-config", () => ({
|
|
34
|
-
__esModule: true,
|
|
35
|
-
get checkoutConfig() {
|
|
36
|
-
return mockSite.value;
|
|
37
|
-
},
|
|
38
|
-
}));
|
|
39
|
-
|
|
40
|
-
const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
|
|
41
|
-
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
|
|
42
|
-
jest.mock("../topology.generated", () => ({
|
|
43
|
-
__esModule: true,
|
|
44
|
-
get TOPOLOGY() {
|
|
45
|
-
return mockTopology.value;
|
|
46
|
-
},
|
|
47
|
-
}));
|
|
48
|
-
|
|
49
|
-
type CheckoutModule = typeof import("../checkout");
|
|
50
|
-
|
|
51
|
-
/** `lib/checkout.ts`, loaded fresh against the current site fixture. */
|
|
52
|
-
function load(site: Record<string, unknown> = { site: "acme", offerings: OFFERINGS }): CheckoutModule {
|
|
53
|
-
mockSite.value = site;
|
|
54
|
-
let loaded!: CheckoutModule;
|
|
55
|
-
jest.isolateModules(() => {
|
|
56
|
-
loaded = require("../checkout") as CheckoutModule;
|
|
57
|
-
});
|
|
58
|
-
return loaded;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** A test-mode completion as the package hands it to `fulfil`. */
|
|
62
|
-
const COMPLETION = {
|
|
63
|
-
idempotencyKey: "cs_test_1",
|
|
64
|
-
sessionId: "cs_test_1",
|
|
65
|
-
offering: "starter",
|
|
66
|
-
kind: "fixed" as const,
|
|
67
|
-
mode: "test" as const,
|
|
68
|
-
livemode: false,
|
|
69
|
-
paymentStatus: "paid" as const,
|
|
70
|
-
amountTotal: 4900,
|
|
71
|
-
currency: "usd",
|
|
72
|
-
email: "b@example.com",
|
|
73
|
-
customerId: "cus_test_1",
|
|
74
|
-
termsOfService: null,
|
|
75
|
-
termsStamp: null,
|
|
76
|
-
metadata: { site: "acme", offering: "starter", kind: "fixed" },
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* `renderConfig`'s body for COMPLETION, key order included (NFR5), plus the
|
|
81
|
-
* session's terms consent as its last key (#8778).
|
|
82
|
-
*/
|
|
83
|
-
const EXPECTED_BODY =
|
|
84
|
-
'{"siteId":"acme","source":"web","email":"b@example.com","payload":{"site":"acme","page":"checkout","offering":"starter","checkoutKind":"fixed","mode":"test","amountTotal":4900,"currency":"usd","stripeCustomerId":"cus_test_1","checkoutSession":"cs_test_1","termsAccepted":null}}';
|
|
85
|
-
|
|
86
|
-
const SOURCE = readFileSync(join(__dirname, "..", "checkout.ts"), "utf8");
|
|
87
|
-
const ENV = process.env;
|
|
88
|
-
|
|
89
|
-
beforeEach(() => {
|
|
90
|
-
mockSubmit.mockReset();
|
|
91
|
-
mockSubmit.mockResolvedValue({ ok: true, status: 201 });
|
|
92
|
-
process.env = { ...ENV, AILK_SPOKE_KEY: "spoke-key-fixture" };
|
|
93
|
-
mockTopology.value = "spoke";
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
afterAll(() => {
|
|
97
|
-
process.env = ENV;
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
describe("spokeCheckoutConfig (#8748 AC-2, AC-4)", () => {
|
|
101
|
-
it("exports exactly spokeCheckoutConfig, checkoutServed and checkoutNotServed", () => {
|
|
102
|
-
expect(Object.keys(load()).sort()).toEqual([
|
|
103
|
-
"checkoutNotServed",
|
|
104
|
-
"checkoutServed",
|
|
105
|
-
"spokeCheckoutConfig",
|
|
106
|
-
]);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it("has exactly site, offerings, siteBaseUrl and fulfil, the offerings being the site's own object", () => {
|
|
110
|
-
const { spokeCheckoutConfig } = load();
|
|
111
|
-
expect(Object.keys(spokeCheckoutConfig).sort()).toEqual([
|
|
112
|
-
"fulfil",
|
|
113
|
-
"offerings",
|
|
114
|
-
"site",
|
|
115
|
-
"siteBaseUrl",
|
|
116
|
-
]);
|
|
117
|
-
expect(spokeCheckoutConfig.site).toBe("acme");
|
|
118
|
-
expect(spokeCheckoutConfig.offerings).toBe(OFFERINGS);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("takes only site and offerings from a site file that also carries env, siteBaseUrl and fulfil (SEC-8748-B)", async () => {
|
|
122
|
-
const siteFulfil = jest.fn();
|
|
123
|
-
const { spokeCheckoutConfig } = load({
|
|
124
|
-
site: "acme",
|
|
125
|
-
offerings: OFFERINGS,
|
|
126
|
-
env: { STRIPE_MODE: "live" },
|
|
127
|
-
siteBaseUrl: "https://elsewhere.example",
|
|
128
|
-
fulfil: siteFulfil,
|
|
129
|
-
});
|
|
130
|
-
expect(Object.keys(spokeCheckoutConfig).sort()).toEqual([
|
|
131
|
-
"fulfil",
|
|
132
|
-
"offerings",
|
|
133
|
-
"site",
|
|
134
|
-
"siteBaseUrl",
|
|
135
|
-
]);
|
|
136
|
-
expect("env" in spokeCheckoutConfig).toBe(false);
|
|
137
|
-
expect(spokeCheckoutConfig.siteBaseUrl).not.toBe("https://elsewhere.example");
|
|
138
|
-
expect(spokeCheckoutConfig.fulfil).not.toBe(siteFulfil);
|
|
139
|
-
await spokeCheckoutConfig.fulfil(COMPLETION);
|
|
140
|
-
expect(siteFulfil).not.toHaveBeenCalled();
|
|
141
|
-
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
|
|
145
|
-
expect(SOURCE).not.toContain("STRIPE_");
|
|
146
|
-
expect(SOURCE).not.toContain("stripe-client");
|
|
147
|
-
});
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
describe("checkoutServed / checkoutNotServed (#8748 D11)", () => {
|
|
151
|
-
it("serves on a spoke only", () => {
|
|
152
|
-
const { checkoutServed } = load();
|
|
153
|
-
mockTopology.value = "spoke";
|
|
154
|
-
expect(checkoutServed()).toBe(true);
|
|
155
|
-
mockTopology.value = "hub";
|
|
156
|
-
expect(checkoutServed()).toBe(false);
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
it("answers 404 with no-store", () => {
|
|
160
|
-
const res = load().checkoutNotServed();
|
|
161
|
-
expect(res.status).toBe(404);
|
|
162
|
-
expect(res.headers.get("cache-control")).toBe("no-store");
|
|
163
|
-
});
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
describe("the fulfilment (#8748 AC-3)", () => {
|
|
167
|
-
it("posts one lead marked with the site, under the session id as its Idempotency-Key", async () => {
|
|
168
|
-
await load().spokeCheckoutConfig.fulfil(COMPLETION);
|
|
169
|
-
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
170
|
-
const [body, init] = mockSubmit.mock.calls[0] as [unknown, unknown];
|
|
171
|
-
expect(body).toEqual({
|
|
172
|
-
siteId: "acme",
|
|
173
|
-
source: "web",
|
|
174
|
-
email: "b@example.com",
|
|
175
|
-
payload: {
|
|
176
|
-
site: "acme",
|
|
177
|
-
page: "checkout",
|
|
178
|
-
offering: "starter",
|
|
179
|
-
checkoutKind: "fixed",
|
|
180
|
-
mode: "test",
|
|
181
|
-
amountTotal: 4900,
|
|
182
|
-
currency: "usd",
|
|
183
|
-
stripeCustomerId: "cus_test_1",
|
|
184
|
-
checkoutSession: "cs_test_1",
|
|
185
|
-
termsAccepted: null,
|
|
186
|
-
},
|
|
187
|
-
});
|
|
188
|
-
expect(init).toEqual({ headers: { "Idempotency-Key": "cs_test_1" } });
|
|
189
|
-
expect(JSON.stringify(body)).toBe(EXPECTED_BODY);
|
|
190
|
-
});
|
|
191
|
-
|
|
192
|
-
it("carries the stamp of the terms the buyer accepted as payload.termsAccepted (#8778)", async () => {
|
|
193
|
-
await load().spokeCheckoutConfig.fulfil({
|
|
194
|
-
...COMPLETION,
|
|
195
|
-
termsOfService: "accepted",
|
|
196
|
-
termsStamp: "account_terms@2",
|
|
197
|
-
});
|
|
198
|
-
const [body] = mockSubmit.mock.calls[0] as [{ payload: Record<string, unknown> }];
|
|
199
|
-
expect(body.payload["termsAccepted"]).toBe("account_terms@2");
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it.each([
|
|
203
|
-
["a box not accepted", { termsOfService: null, termsStamp: "account_terms@2" }],
|
|
204
|
-
["an accepted box with no stamp", { termsOfService: "accepted" as const, termsStamp: null }],
|
|
205
|
-
])("sends termsAccepted: null for %s (#8778)", async (_label, terms) => {
|
|
206
|
-
await load().spokeCheckoutConfig.fulfil({ ...COMPLETION, ...terms });
|
|
207
|
-
const [body] = mockSubmit.mock.calls[0] as [{ payload: Record<string, unknown> }];
|
|
208
|
-
expect(body.payload).toHaveProperty("termsAccepted", null);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("sends the same bytes under the same key on a replay", async () => {
|
|
212
|
-
const { spokeCheckoutConfig } = load();
|
|
213
|
-
await spokeCheckoutConfig.fulfil(COMPLETION);
|
|
214
|
-
await spokeCheckoutConfig.fulfil(COMPLETION);
|
|
215
|
-
expect(mockSubmit).toHaveBeenCalledTimes(2);
|
|
216
|
-
const [first, second] = mockSubmit.mock.calls as [
|
|
217
|
-
[unknown, unknown],
|
|
218
|
-
[unknown, unknown],
|
|
219
|
-
];
|
|
220
|
-
expect(JSON.stringify(second[0])).toBe(JSON.stringify(first[0]));
|
|
221
|
-
expect(second[1]).toEqual(first[1]);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
it("makes no call for a completion without an email", async () => {
|
|
225
|
-
await load().spokeCheckoutConfig.fulfil({ ...COMPLETION, email: null });
|
|
226
|
-
expect(mockSubmit).not.toHaveBeenCalled();
|
|
227
|
-
});
|
|
228
|
-
|
|
229
|
-
it("rejects with no call when AILK_SPOKE_KEY is unset", async () => {
|
|
230
|
-
delete process.env["AILK_SPOKE_KEY"];
|
|
231
|
-
await expect(load().spokeCheckoutConfig.fulfil(COMPLETION)).rejects.toThrow(
|
|
232
|
-
"AILK_SPOKE_KEY is not set",
|
|
233
|
-
);
|
|
234
|
-
expect(mockSubmit).not.toHaveBeenCalled();
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
it("rejects when the hub answers { ok: false }", async () => {
|
|
238
|
-
mockSubmit.mockResolvedValue({ ok: false, status: 422 });
|
|
239
|
-
await expect(load().spokeCheckoutConfig.fulfil(COMPLETION)).rejects.toThrow(
|
|
240
|
-
"hub lead write answered 422",
|
|
241
|
-
);
|
|
242
|
-
});
|
|
243
|
-
});
|
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
// oss: false
|
|
2
|
-
/**
|
|
3
|
-
* Tests for enabledMeetingSlugs (#6183 S9b D5) — the D4-resolving read both
|
|
4
|
-
* /meet pages share — and its spoke branch (#7872 D4).
|
|
5
|
-
*
|
|
6
|
-
* The topology seam is mocked: its hub render imports `../auth`, which this
|
|
7
|
-
* suite has no reason to load (`app/openapi.json/__tests__/route.test.ts`'s
|
|
8
|
-
* pattern).
|
|
9
|
-
*/
|
|
10
|
-
const mockMeetingTypes = jest.fn();
|
|
11
|
-
jest.mock("@working-theory/api-client", () => ({
|
|
12
|
-
__esModule: true,
|
|
13
|
-
schedule: { meetingTypes: (...args: unknown[]) => mockMeetingTypes(...args) },
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
const mockTopology: { value: "hub" | "spoke" } = { value: "hub" };
|
|
17
|
-
// eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426 D5), not a runtime input; the seam IS the boundary between a hub and a spoke build, and there is no env or HTTP surface that selects it.
|
|
18
|
-
jest.mock("../topology.generated", () => ({
|
|
19
|
-
get TOPOLOGY() {
|
|
20
|
-
return mockTopology.value;
|
|
21
|
-
},
|
|
22
|
-
}));
|
|
23
|
-
|
|
24
|
-
import { enabledMeetingSlugs } from "../meeting-slugs";
|
|
25
|
-
|
|
26
|
-
describe("enabledMeetingSlugs", () => {
|
|
27
|
-
const originalEnv = process.env["SCHEDULING_CAL_LINK"];
|
|
28
|
-
|
|
29
|
-
beforeEach(() => {
|
|
30
|
-
mockMeetingTypes.mockReset();
|
|
31
|
-
mockTopology.value = "hub";
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
afterEach(() => {
|
|
35
|
-
if (originalEnv === undefined) delete process.env["SCHEDULING_CAL_LINK"];
|
|
36
|
-
else process.env["SCHEDULING_CAL_LINK"] = originalEnv;
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("passes a real, populated array through unchanged", async () => {
|
|
40
|
-
mockMeetingTypes.mockResolvedValue({
|
|
41
|
-
ok: true,
|
|
42
|
-
body: { slugs: ["intro", "demo"] },
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
await expect(enabledMeetingSlugs()).resolves.toEqual(["intro", "demo"]);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
it("passes a real, intentionally empty array through unchanged (D4 row 2 — never the fallback)", async () => {
|
|
49
|
-
process.env["SCHEDULING_CAL_LINK"] = "acme/intro-call";
|
|
50
|
-
mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: [] } });
|
|
51
|
-
|
|
52
|
-
await expect(enabledMeetingSlugs()).resolves.toEqual([]);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("null + SCHEDULING_CAL_LINK set → the derived singleton (D4 row 1)", async () => {
|
|
56
|
-
process.env["SCHEDULING_CAL_LINK"] = "acme/intro-call";
|
|
57
|
-
mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: null } });
|
|
58
|
-
|
|
59
|
-
await expect(enabledMeetingSlugs()).resolves.toEqual(["intro-call"]);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it("null + SCHEDULING_CAL_LINK unset → [] (no shipped page to protect)", async () => {
|
|
63
|
-
delete process.env["SCHEDULING_CAL_LINK"];
|
|
64
|
-
mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: null } });
|
|
65
|
-
|
|
66
|
-
await expect(enabledMeetingSlugs()).resolves.toEqual([]);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("a non-ok API result → [], fail closed — an outage must not open the gate", async () => {
|
|
70
|
-
process.env["SCHEDULING_CAL_LINK"] = "acme/intro-call";
|
|
71
|
-
mockMeetingTypes.mockResolvedValue({ ok: false, status: 500, body: null });
|
|
72
|
-
|
|
73
|
-
await expect(enabledMeetingSlugs()).resolves.toEqual([]);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it("null + a MALFORMED SCHEDULING_CAL_LINK slug → [], never a corrupt fallback (D14 parity, security review 2026-09-19)", async () => {
|
|
77
|
-
// A comma in the derived slug would corrupt the batched event-meta
|
|
78
|
-
// `calLinks` join downstream (it splits on comma), misattributing the
|
|
79
|
-
// rest of the list to a foreign owner — MeetingSlugSchema rejects it.
|
|
80
|
-
process.env["SCHEDULING_CAL_LINK"] = "acme/intro,evil";
|
|
81
|
-
mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: null } });
|
|
82
|
-
|
|
83
|
-
await expect(enabledMeetingSlugs()).resolves.toEqual([]);
|
|
84
|
-
});
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
describe("enabledMeetingSlugs on a spoke (#7872 D4)", () => {
|
|
88
|
-
const originalEnv = process.env["SCHEDULING_CAL_LINK"];
|
|
89
|
-
|
|
90
|
-
beforeEach(() => {
|
|
91
|
-
mockMeetingTypes.mockReset();
|
|
92
|
-
mockTopology.value = "spoke";
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
afterEach(() => {
|
|
96
|
-
mockTopology.value = "hub";
|
|
97
|
-
if (originalEnv === undefined) delete process.env["SCHEDULING_CAL_LINK"];
|
|
98
|
-
else process.env["SCHEDULING_CAL_LINK"] = originalEnv;
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
it("null + SCHEDULING_CAL_LINK set → [], never the fallback: the hub books only a stored slug", async () => {
|
|
102
|
-
process.env["SCHEDULING_CAL_LINK"] = "acme/intro-call";
|
|
103
|
-
mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: null } });
|
|
104
|
-
|
|
105
|
-
await expect(enabledMeetingSlugs()).resolves.toEqual([]);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it("passes the workspace's stored list through unchanged", async () => {
|
|
109
|
-
mockMeetingTypes.mockResolvedValue({ ok: true, body: { slugs: ["intro"] } });
|
|
110
|
-
|
|
111
|
-
await expect(enabledMeetingSlugs()).resolves.toEqual(["intro"]);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it("a non-ok API result → [], fail closed", async () => {
|
|
115
|
-
process.env["SCHEDULING_CAL_LINK"] = "acme/intro-call";
|
|
116
|
-
mockMeetingTypes.mockResolvedValue({ ok: false, status: 401, body: null });
|
|
117
|
-
|
|
118
|
-
await expect(enabledMeetingSlugs()).resolves.toEqual([]);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
// checkout.ts — the standard spoke checkout (#8748).
|
|
2
|
-
//
|
|
3
|
-
// The standard every selling spoke shares, kept current by `create-ailk
|
|
4
|
-
// update`: do not edit it. This site's checkout is data in
|
|
5
|
-
// lib/checkout-config.ts, the one file to edit. Only the buy route and the
|
|
6
|
-
// Stripe webhook route import this file, and it never imports the checkout
|
|
7
|
-
// package: every guarantee lives there and arrives by a package bump.
|
|
8
|
-
//
|
|
9
|
-
// spokeCheckoutConfig What the two routes build their handlers from. It reads
|
|
10
|
-
// only `site` and `offerings` from the site's file, so no
|
|
11
|
-
// other key a site declares reaches the package.
|
|
12
|
-
// fulfil Runs for each paid session, at least once. It posts one
|
|
13
|
-
// lead to the hub with the session id as its
|
|
14
|
-
// Idempotency-Key, so a repeat writes nothing new. The hub
|
|
15
|
-
// dedupes only an identical body: change the payload's
|
|
16
|
-
// shape only when no webhook is failing. `termsAccepted`
|
|
17
|
-
// is the stamp of the terms the buyer ticked in Stripe's
|
|
18
|
-
// box, else null: the hub records the buyer's checkout
|
|
19
|
-
// consent grant from it (#8778).
|
|
20
|
-
import { leads } from "@working-theory/api-client";
|
|
21
|
-
|
|
22
|
-
import { resolveBaseUrl } from "../src/lib/routes";
|
|
23
|
-
|
|
24
|
-
import { checkoutConfig } from "./checkout-config";
|
|
25
|
-
import { TOPOLOGY } from "./topology.generated";
|
|
26
|
-
|
|
27
|
-
/** What the fulfilment reads from a completed checkout. */
|
|
28
|
-
interface CompletedCheckout {
|
|
29
|
-
idempotencyKey: string;
|
|
30
|
-
sessionId: string;
|
|
31
|
-
offering: string;
|
|
32
|
-
kind: "fixed" | "quoted";
|
|
33
|
-
mode: "test" | "live";
|
|
34
|
-
amountTotal: number | null;
|
|
35
|
-
currency: string | null;
|
|
36
|
-
email: string | null;
|
|
37
|
-
customerId: string | null;
|
|
38
|
-
termsOfService: "accepted" | null;
|
|
39
|
-
termsStamp: string | null;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async function fulfil(checkout: CompletedCheckout): Promise<void> {
|
|
43
|
-
if (checkout.email === null) return;
|
|
44
|
-
// No spoke key: the hub cannot place the lead in this site's workspace.
|
|
45
|
-
// Fail, so Stripe retries and the dashboard shows a failed webhook.
|
|
46
|
-
if (!process.env.AILK_SPOKE_KEY)
|
|
47
|
-
throw new Error("AILK_SPOKE_KEY is not set");
|
|
48
|
-
const result = await leads.submit(
|
|
49
|
-
{
|
|
50
|
-
siteId: checkoutConfig.site,
|
|
51
|
-
source: "web",
|
|
52
|
-
email: checkout.email,
|
|
53
|
-
payload: {
|
|
54
|
-
site: checkoutConfig.site,
|
|
55
|
-
page: "checkout",
|
|
56
|
-
offering: checkout.offering,
|
|
57
|
-
checkoutKind: checkout.kind,
|
|
58
|
-
mode: checkout.mode,
|
|
59
|
-
amountTotal: checkout.amountTotal,
|
|
60
|
-
currency: checkout.currency,
|
|
61
|
-
stripeCustomerId: checkout.customerId,
|
|
62
|
-
checkoutSession: checkout.sessionId,
|
|
63
|
-
termsAccepted:
|
|
64
|
-
checkout.termsOfService === "accepted" ? checkout.termsStamp : null,
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
{ headers: { "Idempotency-Key": checkout.idempotencyKey } },
|
|
68
|
-
);
|
|
69
|
-
// A replay under the same key answers 201 with the first lead; any
|
|
70
|
-
// failure (422 idempotency_key_reused included) makes Stripe retry.
|
|
71
|
-
if (!result.ok) throw new Error(`hub lead write answered ${result.status}`);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** The config both routes pass to the checkout package, built key by key. */
|
|
75
|
-
export const spokeCheckoutConfig = {
|
|
76
|
-
site: checkoutConfig.site,
|
|
77
|
-
offerings: checkoutConfig.offerings,
|
|
78
|
-
siteBaseUrl: resolveBaseUrl(),
|
|
79
|
-
fulfil,
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/** Whether this build serves the checkout: a spoke only (#8748 D11). */
|
|
83
|
-
export function checkoutServed(): boolean {
|
|
84
|
-
return TOPOLOGY === "spoke";
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** What either route answers on a build that does not serve the checkout. */
|
|
88
|
-
export function checkoutNotServed(): Response {
|
|
89
|
-
return new Response(null, {
|
|
90
|
-
status: 404,
|
|
91
|
-
headers: { "Cache-Control": "no-store" },
|
|
92
|
-
});
|
|
93
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
// oss: false
|
|
2
|
-
/**
|
|
3
|
-
* enabledMeetingSlugs — the enabled-meeting-slugs read both `/meet` pages
|
|
4
|
-
* share (#6183 S9b D5).
|
|
5
|
-
*
|
|
6
|
-
* Resolves `GET /v1/schedule/meeting-types`'s three D4 outcomes into ONE
|
|
7
|
-
* array both pages consume identically:
|
|
8
|
-
*
|
|
9
|
-
* - an array is returned as-is (a real, operator-written enabled list —
|
|
10
|
-
* D4 rows 2 and 3, INCLUDING an intentionally empty `[]`);
|
|
11
|
-
* - `null` becomes the D4 row-1 singleton derived from
|
|
12
|
-
* `SCHEDULING_CAL_LINK` — the ONE slug an unconfigured deployment has
|
|
13
|
-
* ever advertised — or `[]` when that env var is unset;
|
|
14
|
-
* - any non-`ok` API result becomes `[]`, fail closed: an API outage
|
|
15
|
-
* must not open the gate.
|
|
16
|
-
*
|
|
17
|
-
* A duplicated inline call in both pages is how the two would drift, and
|
|
18
|
-
* it is also how one of them would forget the fail-closed arm — this is
|
|
19
|
-
* the ONE place that reasoning lives (D5).
|
|
20
|
-
*
|
|
21
|
-
* ON A SPOKE (#7872 D4), `null` means `[]`: the `SCHEDULING_CAL_LINK`
|
|
22
|
-
* fallback is not consulted. The hub books a spoke's slug only when that
|
|
23
|
-
* spoke's workspace has stored it (`spokeMayBook`), so a fallback slug would
|
|
24
|
-
* advertise a meeting whose every booking fails with `event_not_found`. The
|
|
25
|
-
* other arms are the same on both topologies.
|
|
26
|
-
*/
|
|
27
|
-
import { schedule } from "@working-theory/api-client";
|
|
28
|
-
import { MeetingSlugSchema } from "@working-theory/site-config/schema";
|
|
29
|
-
|
|
30
|
-
import { TOPOLOGY } from "./topology.generated";
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* The D4 row-1 fallback: the slug half of the env-configured booker's own
|
|
34
|
-
* calLink (`"<owner>/<slug>"`), or `[]` when the env var is unset. This is
|
|
35
|
-
* deliberately the ONLY reader of `SCHEDULING_CAL_LINK`'s SECOND segment —
|
|
36
|
-
* `meet/[slug]/page.tsx`'s own `composedCalLink` reads the FIRST segment
|
|
37
|
-
* (the owner) for an unrelated purpose and is not touched by this file.
|
|
38
|
-
*
|
|
39
|
-
* Re-validated against `MeetingSlugSchema` (D14 parity with the operator-
|
|
40
|
-
* written path — `meeting-types.ts`'s own per-entry `safeParse` filter):
|
|
41
|
-
* `SCHEDULING_CAL_LINK` is operator-set env config, not a schema-validated
|
|
42
|
-
* write, so a malformed value (a stray comma, an empty second segment) could
|
|
43
|
-
* otherwise yield a slug that corrupts the batched `event-meta` `calLinks`
|
|
44
|
-
* join downstream — which splits on comma and would misattribute the rest
|
|
45
|
-
* of the list to a foreign owner (security review, 2026-09-19).
|
|
46
|
-
*/
|
|
47
|
-
function derivedFallbackSlugs(): string[] {
|
|
48
|
-
const configured = process.env["SCHEDULING_CAL_LINK"];
|
|
49
|
-
if (!configured) return [];
|
|
50
|
-
const slug = configured.split("/")[1];
|
|
51
|
-
if (!slug) return [];
|
|
52
|
-
const parsed = MeetingSlugSchema.safeParse(slug);
|
|
53
|
-
return parsed.success ? [parsed.data] : [];
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** The enabled meeting-type slug set for this deployment, D4-resolved. */
|
|
57
|
-
export async function enabledMeetingSlugs(): Promise<string[]> {
|
|
58
|
-
const res = await schedule.meetingTypes();
|
|
59
|
-
if (!res.ok) return [];
|
|
60
|
-
const { slugs } = res.body;
|
|
61
|
-
if (slugs) return slugs;
|
|
62
|
-
return TOPOLOGY === "spoke" ? [] : derivedFallbackSlugs();
|
|
63
|
-
}
|