create-ailk 0.15.0 → 0.17.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.
Files changed (128) hide show
  1. package/README.md +124 -0
  2. package/dist/add-module.js +52 -7
  3. package/dist/add-route.js +8 -0
  4. package/dist/adopt.d.ts +74 -0
  5. package/dist/adopt.js +314 -0
  6. package/dist/cli.js +82 -2
  7. package/dist/derive-architecture.d.ts +22 -0
  8. package/dist/derive-architecture.js +58 -1
  9. package/dist/lib/declared-env.d.ts +142 -0
  10. package/dist/lib/declared-env.js +985 -0
  11. package/dist/lib/env-catalog.d.ts +17 -0
  12. package/dist/lib/env-catalog.js +234 -0
  13. package/dist/lib/extract-module-bundle.d.ts +9 -0
  14. package/dist/lib/extract-module-bundle.js +18 -1
  15. package/dist/lib/module-tier.d.ts +2 -0
  16. package/dist/lib/module-tier.js +4 -0
  17. package/dist/module-architecture.js +76 -0
  18. package/dist/parse-args.d.ts +5 -0
  19. package/dist/parse-args.js +17 -1
  20. package/dist/programmatic.d.ts +8 -0
  21. package/dist/programmatic.js +78 -9
  22. package/dist/seed-flows.d.ts +65 -0
  23. package/dist/seed-flows.js +350 -0
  24. package/dist/site-manifest.d.ts +43 -0
  25. package/dist/site-manifest.js +98 -3
  26. package/dist/site-roster.d.ts +13 -0
  27. package/dist/site-roster.js +22 -8
  28. package/dist/spoke-checkout.d.ts +67 -0
  29. package/dist/spoke-checkout.js +562 -0
  30. package/dist/surfaces.js +7 -0
  31. package/dist/sync-routes.js +23 -8
  32. package/dist/topology.d.ts +124 -9
  33. package/dist/topology.js +451 -40
  34. package/package.json +3 -3
  35. package/templates/.env.example +10 -0
  36. package/templates/apps/api/CLAUDE.md +19 -4
  37. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  38. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  39. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  40. package/templates/apps/api/src/bin/spokes.ts +62 -1
  41. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  42. package/templates/apps/api/src/config/index.ts +35 -0
  43. package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
  44. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  45. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  46. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  47. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  48. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  49. package/templates/apps/api/src/lib/spoke-store.ts +25 -3
  50. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  51. package/templates/apps/api/src/middleware/auth.ts +126 -0
  52. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  53. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  54. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
  55. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  56. package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
  57. package/templates/apps/api/src/server.ts +105 -0
  58. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  59. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  60. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
  61. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  62. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  63. package/templates/apps/api/src/services/lead-routing.ts +131 -2
  64. package/templates/apps/api/src/services/person.ts +541 -0
  65. package/templates/apps/web/.env.example +58 -5
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  69. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  70. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  73. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  74. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  75. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  76. package/templates/apps/web/app/[locale]/layout.tsx +28 -15
  77. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  78. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  79. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  80. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  81. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  82. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  83. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +57 -24
  84. package/templates/apps/web/app/openapi.json/route.ts +3 -6
  85. package/templates/apps/web/auth.ts +29 -2
  86. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  87. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  88. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  89. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  90. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  91. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  92. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -0
  93. package/templates/apps/web/lib/__tests__/server-api.test.ts +37 -28
  94. package/templates/apps/web/lib/account-config.ts +130 -1
  95. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  96. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  97. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  98. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  99. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  100. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  101. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  102. package/templates/apps/web/lib/listings-config.ts +31 -0
  103. package/templates/apps/web/lib/listings.tsx +7 -3
  104. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  105. package/templates/apps/web/lib/next-path.ts +35 -0
  106. package/templates/apps/web/lib/relay.ts +3 -1
  107. package/templates/apps/web/lib/server-api.ts +11 -1
  108. package/templates/apps/web/lib/topology.generated.ts +15 -5
  109. package/templates/apps/web/messages/en.json +32 -3
  110. package/templates/database/CHANGELOG.md +26 -0
  111. package/templates/database/__tests__/agents.db.test.ts +25 -5
  112. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  113. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  114. package/templates/database/__tests__/person.db.test.ts +584 -0
  115. package/templates/database/auth/schema.prisma +3 -2
  116. package/templates/database/config/schema.prisma +13 -0
  117. package/templates/database/inbox/schema.prisma +98 -0
  118. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  119. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  120. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  121. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  122. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  123. package/templates/database/package.json +1 -1
  124. package/templates/database/scripts/person-backfill.sql +132 -0
  125. package/templates/database/tenancy.ts +53 -0
  126. package/templates/tools/check-declared-env.ts +37 -0
  127. package/templates/tools/declared-env.ts +1163 -0
  128. package/versions.json +35 -35
@@ -15,6 +15,13 @@
15
15
 
16
16
  import { GET } from "../route";
17
17
 
18
+ // The REAL committed seam, for the #8060 hub-only guards: the mock below
19
+ // shadows `TOPOLOGY` per case, but whether a hub-only case runs depends on the
20
+ // render this scaffold carries, not on the topology a case picks.
21
+ const { TOPOLOGY: SEAM_TOPOLOGY } = jest.requireActual<
22
+ typeof import("../../../lib/topology.generated")
23
+ >("../../../lib/topology.generated");
24
+
18
25
  // The generated topology seam (#7426 D5), mocked so each case picks its
19
26
  // topology: the hub cases below run against `"hub"`, exactly as before; the
20
27
  // spoke cases (D12) flip it. Mocked rather than imported because the hub
@@ -93,16 +100,35 @@ describe("GET /openapi.json", () => {
93
100
  expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
94
101
  });
95
102
 
96
- it("falls back to NEXT_PUBLIC_AILK_API_URL, then the local-dev API origin", async () => {
97
- apiAnswers(200, JSON.stringify(SPEC));
98
- await GET();
99
- expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
103
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
104
+ (SEAM_TOPOLOGY === "hub" ? it : it.skip)(
105
+ "falls back to NEXT_PUBLIC_AILK_API_URL, then the local-dev API origin",
106
+ async () => {
107
+ apiAnswers(200, JSON.stringify(SPEC));
108
+ await GET();
109
+ expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
110
+
111
+ fetchSpy.mockClear();
112
+ process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
113
+ apiAnswers(200, JSON.stringify(SPEC));
114
+ await GET();
115
+ expect(requestedUrl()).toBe("https://api.example.com/v1/openapi.json");
116
+ },
117
+ );
100
118
 
101
- fetchSpy.mockClear();
119
+ // #7874 D12 — the hub case above runs the committed (hub) render of the
120
+ // browser API origin seam unmocked; the spoke render reads no env at all.
121
+ it("skips the NEXT_PUBLIC_AILK_API_URL fallback under the seam's spoke render", async () => {
102
122
  process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
103
123
  apiAnswers(200, JSON.stringify(SPEC));
104
- await GET();
105
- expect(requestedUrl()).toBe("https://api.example.com/v1/openapi.json");
124
+ await jest.isolateModulesAsync(async () => {
125
+ jest.doMock("../../../lib/api-origin.generated", () => ({
126
+ browserApiOrigin: (): string | undefined => undefined,
127
+ }));
128
+ const spoke = await import("../route");
129
+ await spoke.GET();
130
+ });
131
+ expect(requestedUrl()).toBe("http://localhost:3001/v1/openapi.json");
106
132
  });
107
133
 
108
134
  it("answers 502, not 404, when the API cannot be reached", async () => {
@@ -126,23 +152,30 @@ describe("GET /openapi.json", () => {
126
152
  expect(response.headers.get("cache-control")).toBe("no-store");
127
153
  });
128
154
 
129
- it("resolves a relative servers[] URL against the public API origin", async () => {
130
- process.env["AILK_API_URL"] = "http://api.internal:4000";
131
- process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
132
- apiAnswers(
133
- 200,
134
- JSON.stringify({ ...SPEC, servers: [{ url: "/", description: "x" }] }),
135
- );
136
-
137
- const body = (await (await GET()).json()) as typeof SPEC & {
138
- servers: Array<{ url: string }>;
139
- };
140
-
141
- expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
142
- expect(body.servers).toEqual([
143
- { url: "https://api.example.com", description: "This deployment's API" },
144
- ]);
145
- });
155
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
156
+ (SEAM_TOPOLOGY === "hub" ? it : it.skip)(
157
+ "resolves a relative servers[] URL against the public API origin",
158
+ async () => {
159
+ process.env["AILK_API_URL"] = "http://api.internal:4000";
160
+ process.env["NEXT_PUBLIC_AILK_API_URL"] = "https://api.example.com/";
161
+ apiAnswers(
162
+ 200,
163
+ JSON.stringify({ ...SPEC, servers: [{ url: "/", description: "x" }] }),
164
+ );
165
+
166
+ const body = (await (await GET()).json()) as typeof SPEC & {
167
+ servers: Array<{ url: string }>;
168
+ };
169
+
170
+ expect(requestedUrl()).toBe("http://api.internal:4000/v1/openapi.json");
171
+ expect(body.servers).toEqual([
172
+ {
173
+ url: "https://api.example.com",
174
+ description: "This deployment's API",
175
+ },
176
+ ]);
177
+ },
178
+ );
146
179
 
147
180
  it("resolves a relative servers[] URL against AILK_API_URL when no public origin is set", async () => {
148
181
  process.env["AILK_API_URL"] = "http://localhost:3700";
@@ -23,6 +23,7 @@
23
23
  * host, and never calls a path this site would answer 404.
24
24
  */
25
25
 
26
+ import { browserApiOrigin } from "../../lib/api-origin.generated";
26
27
  import { RELAY_ROUTES } from "../../lib/relay";
27
28
  import { TOPOLOGY } from "../../lib/topology.generated";
28
29
  import { resolveBaseUrl } from "../../src/lib/routes";
@@ -43,9 +44,7 @@ const LOCAL_API_ORIGIN = "http://localhost:3001";
43
44
  */
44
45
  function apiBase(): string {
45
46
  return stripSlash(
46
- process.env["AILK_API_URL"] ||
47
- process.env["NEXT_PUBLIC_AILK_API_URL"] ||
48
- LOCAL_API_ORIGIN,
47
+ process.env["AILK_API_URL"] || browserApiOrigin() || LOCAL_API_ORIGIN,
49
48
  );
50
49
  }
51
50
 
@@ -55,9 +54,7 @@ function apiBase(): string {
55
54
  */
56
55
  function publicApiBase(): string {
57
56
  return stripSlash(
58
- process.env["NEXT_PUBLIC_AILK_API_URL"] ||
59
- process.env["AILK_API_URL"] ||
60
- LOCAL_API_ORIGIN,
57
+ browserApiOrigin() || process.env["AILK_API_URL"] || LOCAL_API_ORIGIN,
61
58
  );
62
59
  }
63
60
 
@@ -134,13 +134,16 @@ function deferredPrismaForAuth(): PrismaClient {
134
134
  }
135
135
 
136
136
  /**
137
- * The one delegate method `onPasswordReset` reaches for, named so the call
137
+ * The two delegate methods `onPasswordReset` reaches for, named so the call
138
138
  * site needs no cast. Method shorthand for the same reason `AuthEmailPrisma`
139
139
  * uses it: the generated `PrismaClient` satisfies this structurally, and
140
- * nothing wider than `verification.deleteMany` is reachable through it.
140
+ * nothing wider than `verification.deleteMany` and
141
+ * `oAuthAuthorization.updateMany` is reachable through it. The second ends
142
+ * every credential a spoke holds for the person (#7894 D4, SEC-7894-5).
141
143
  */
142
144
  interface VerificationStore {
143
145
  verification: { deleteMany(args: unknown): Promise<unknown> };
146
+ oAuthAuthorization: { updateMany(args: unknown): Promise<unknown> };
144
147
  }
145
148
 
146
149
  type SocialProviders = {
@@ -364,6 +367,30 @@ export function createAuth(deps: CreateAuthDeps = {}) {
364
367
  })}`,
365
368
  );
366
369
  }
370
+ /**
371
+ * A completed reset also ends every credential a SPOKE holds for this
372
+ * person (#7894 D4, SEC-7894-5). A hub session an attacker held minted
373
+ * spoke credentials of its own; recovering the account must end those too,
374
+ * or the attacker keeps reading the person's data on every spoke for the
375
+ * rest of the credential's 24 hours. The same posture as the sibling
376
+ * delete above: the reset ALREADY SUCCEEDED, so a failed revoke is
377
+ * logged (the user id and a reason, never a credential) and never turns
378
+ * it into a 500.
379
+ */
380
+ try {
381
+ await verificationStore.oAuthAuthorization.updateMany({
382
+ where: { userId: user.id, revokedAt: null },
383
+ data: { revokedAt: new Date() },
384
+ });
385
+ } catch {
386
+ console.warn(
387
+ `[auth-email] ${JSON.stringify({
388
+ userId: user.id,
389
+ kind: "password-reset",
390
+ reason: "spoke_credential_revocation_failed",
391
+ })}`,
392
+ );
393
+ }
367
394
  /**
368
395
  * Guarded like its sibling above, though `lib/auth-email.ts` now also
369
396
  * guards its own signing step (#6068). BOTH, deliberately: this callback
@@ -3,6 +3,11 @@
3
3
  /**
4
4
  * SignOutTrigger — wraps the signed-in Nav's "Sign out" trigger (#4984).
5
5
  *
6
+ * With an `endpoint` (a spoke that signs in through its hub, #7894 D10) it
7
+ * POSTs there — the hub-session sign-out, which revokes the credential and
8
+ * clears the session cookie — and navigates home only when that succeeded;
9
+ * without one it calls Better Auth's client exactly as before.
10
+ *
6
11
  * The locale layout (RSC) reads the session and authors the "Sign out" nav
7
12
  * action as `kind: "flow"`; Nav hands the rendered trigger `<Button>` back
8
13
  * through `renderFlowTrigger` — composed in `SignedInNav`, on the client side
@@ -22,16 +27,30 @@ export interface SignOutTriggerProps {
22
27
  children: ReactNode;
23
28
  /** Where to land after signing out. Defaults to the site home. */
24
29
  homeHref?: string;
30
+ /** A same-origin sign-out route to POST to instead of Better Auth's client. */
31
+ endpoint?: string;
25
32
  }
26
33
 
27
34
  export function SignOutTrigger({
28
35
  children,
29
36
  homeHref = "/",
37
+ endpoint,
30
38
  }: SignOutTriggerProps): ReactElement {
31
39
  const router = useRouter();
32
40
 
33
41
  const handleSignOut = async (): Promise<void> => {
34
- await authClient.signOut();
42
+ if (endpoint !== undefined) {
43
+ // A refused or failed sign-out leaves the visitor signed in, so the
44
+ // page must not pretend otherwise by navigating away.
45
+ try {
46
+ const res = await fetch(endpoint, { method: "POST", credentials: "same-origin" });
47
+ if (!res.ok) return;
48
+ } catch {
49
+ return;
50
+ }
51
+ } else {
52
+ await authClient.signOut();
53
+ }
35
54
  router.push(homeHref);
36
55
  router.refresh();
37
56
  };
@@ -32,9 +32,19 @@ import { SignOutTrigger } from "./sign-out-trigger";
32
32
  export type SignedInNavProps = Omit<
33
33
  NavProps,
34
34
  "navItems" | "renderFlowTrigger" | "translate" | "onSearchOpen"
35
- >;
35
+ > & {
36
+ /**
37
+ * Where "Sign out" POSTs, from the generated seam's `SIGN_OUT_ENDPOINT`
38
+ * (#7894 D10) — a spoke that signs in through its hub. Absent or `null`,
39
+ * Sign out goes through Better Auth's client, as on a hub.
40
+ */
41
+ signOutEndpoint?: string | null;
42
+ };
36
43
 
37
- export function SignedInNav(props: SignedInNavProps): ReactElement {
44
+ export function SignedInNav({
45
+ signOutEndpoint,
46
+ ...props
47
+ }: SignedInNavProps): ReactElement {
38
48
  return (
39
49
  <Nav
40
50
  {...props}
@@ -43,7 +53,11 @@ export function SignedInNav(props: SignedInNavProps): ReactElement {
43
53
  // SignOutTrigger, the account action a non-navigating name label.
44
54
  renderFlowTrigger={(action, trigger) =>
45
55
  action.labelKey === "signOut" ? (
46
- <SignOutTrigger>{trigger}</SignOutTrigger>
56
+ <SignOutTrigger
57
+ {...(signOutEndpoint ? { endpoint: signOutEndpoint } : {})}
58
+ >
59
+ {trigger}
60
+ </SignOutTrigger>
47
61
  ) : (
48
62
  <span
49
63
  className="text-body-sm text-(--color-text-muted)"
@@ -107,10 +107,12 @@ describe("account-config exports (AC-6)", () => {
107
107
  expect(Array.isArray(accountForYouSources)).toBe(true);
108
108
  });
109
109
 
110
- it("ships exactly the two base Inbox kinds, both Recents rows (D6a, D9)", () => {
110
+ it("ships exactly the base Inbox kinds and the member kinds (#7920), all Recents rows (D6a, D9)", () => {
111
111
  expect(accountInboxKinds.map((k) => [k.kind, k.section])).toEqual([
112
112
  ["result", "updates"],
113
113
  ["purchase", "updates"],
114
+ ["piece", "updates"],
115
+ ["message", "updates"],
114
116
  ]);
115
117
  // Nothing in the base area fills Scheduled (the old Upcoming), and no
116
118
  // Inbox row asks for a decision — that is the For-you panel's job.
@@ -118,10 +120,11 @@ describe("account-config exports (AC-6)", () => {
118
120
  expect(accountInboxKinds.some((k) => k.section === "needs-you")).toBe(false);
119
121
  });
120
122
 
121
- it("ships email-unverified and payment-due as For-you sources, not Inbox kinds (D9 as amended)", () => {
123
+ it("ships email-unverified, payment-due and piece-needs-client (#7920) as For-you sources, not Inbox kinds (D9 as amended)", () => {
122
124
  expect(accountForYouSources.map((s) => s.id)).toEqual([
123
125
  "email-unverified",
124
126
  "payment-due",
127
+ "piece-needs-client",
125
128
  ]);
126
129
  const kinds = accountInboxKinds.map((k) => k.kind);
127
130
  expect(kinds).not.toContain("email-unverified");
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The one reader of the listing site key (#7872 D8, AC-6).
3
+ *
4
+ * `NEXT_PUBLIC_AILK_LISTING_SITE_KEY` wins; `AILK_LISTING_PUBLIC_KEY` is the
5
+ * fallback every site configured since create-ailk 0.12.0 already sets; an
6
+ * empty or whitespace value counts as unset, so the listicle directory stays
7
+ * off rather than reading the hub with a blank key.
8
+ */
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ import { listingSiteKey, listingsEnabled } from "../listings-config";
13
+
14
+ const PUBLIC_NAME = "NEXT_PUBLIC_AILK_LISTING_SITE_KEY";
15
+ const FALLBACK_NAME = "AILK_LISTING_PUBLIC_KEY";
16
+
17
+ const original = {
18
+ public: process.env[PUBLIC_NAME],
19
+ fallback: process.env[FALLBACK_NAME],
20
+ };
21
+
22
+ function setEnv(name: string, value: string | undefined): void {
23
+ if (value === undefined) delete process.env[name];
24
+ else process.env[name] = value;
25
+ }
26
+
27
+ afterEach(() => {
28
+ setEnv(PUBLIC_NAME, original.public);
29
+ setEnv(FALLBACK_NAME, original.fallback);
30
+ });
31
+
32
+ describe("listingSiteKey / listingsEnabled", () => {
33
+ it("answers the trimmed public name when both are set", () => {
34
+ setEnv(PUBLIC_NAME, " site-public ");
35
+ setEnv(FALLBACK_NAME, "site-fallback");
36
+
37
+ expect(listingSiteKey()).toBe("site-public");
38
+ expect(listingsEnabled()).toBe(true);
39
+ });
40
+
41
+ it("answers the trimmed fallback when only it is set", () => {
42
+ setEnv(PUBLIC_NAME, undefined);
43
+ setEnv(FALLBACK_NAME, " site-fallback\n");
44
+
45
+ expect(listingSiteKey()).toBe("site-fallback");
46
+ expect(listingsEnabled()).toBe(true);
47
+ });
48
+
49
+ it("falls back when the public name is whitespace only", () => {
50
+ setEnv(PUBLIC_NAME, " ");
51
+ setEnv(FALLBACK_NAME, "site-fallback");
52
+
53
+ expect(listingSiteKey()).toBe("site-fallback");
54
+ expect(listingsEnabled()).toBe(true);
55
+ });
56
+
57
+ it("answers undefined when neither is set", () => {
58
+ setEnv(PUBLIC_NAME, undefined);
59
+ setEnv(FALLBACK_NAME, undefined);
60
+
61
+ expect(listingSiteKey()).toBeUndefined();
62
+ expect(listingsEnabled()).toBe(false);
63
+ });
64
+
65
+ it("answers undefined when both are empty or whitespace", () => {
66
+ setEnv(PUBLIC_NAME, "");
67
+ setEnv(FALLBACK_NAME, " \t");
68
+
69
+ expect(listingSiteKey()).toBeUndefined();
70
+ expect(listingsEnabled()).toBe(false);
71
+ });
72
+ });
73
+
74
+ describe("apps/web/.env.example", () => {
75
+ it("documents both names", () => {
76
+ const example = readFileSync(
77
+ join(__dirname, "..", "..", ".env.example"),
78
+ "utf8",
79
+ );
80
+ expect(example).toMatch(new RegExp(`^# ${PUBLIC_NAME}=`, "m"));
81
+ expect(example).toMatch(new RegExp(`^# ${FALLBACK_NAME}=`, "m"));
82
+ });
83
+ });
@@ -294,6 +294,55 @@ describe("no read without a key or the paid package", () => {
294
294
  });
295
295
  });
296
296
 
297
+ describe("the default key reader is listings-config's (#7872 D8)", () => {
298
+ const PUBLIC_NAME = "NEXT_PUBLIC_AILK_LISTING_SITE_KEY";
299
+ const FALLBACK_NAME = "AILK_LISTING_PUBLIC_KEY";
300
+ const original = { public: process.env[PUBLIC_NAME], fallback: process.env[FALLBACK_NAME] };
301
+
302
+ function setEnv(name: string, value: string | undefined): void {
303
+ if (value === undefined) delete process.env[name];
304
+ else process.env[name] = value;
305
+ }
306
+
307
+ /** Every dependency but the key: the extension falls back to its own reader. */
308
+ function keylessDepsExtension() {
309
+ return createListingExtension({
310
+ pathname: async () => path,
311
+ locales: () => ["en", "fr"],
312
+ fetchDirectory,
313
+ loadUi: async () => STUB_UI,
314
+ translate: async () => (key: string) => `listings.${key}`,
315
+ baseUrl: () => "https://site.test",
316
+ });
317
+ }
318
+
319
+ afterEach(() => {
320
+ setEnv(PUBLIC_NAME, original.public);
321
+ setEnv(FALLBACK_NAME, original.fallback);
322
+ });
323
+
324
+ it("reads the directory with NEXT_PUBLIC_AILK_LISTING_SITE_KEY", async () => {
325
+ setEnv(PUBLIC_NAME, "k");
326
+ setEnv(FALLBACK_NAME, undefined);
327
+ path = "/en/partners";
328
+
329
+ await keylessDepsExtension().appendix!({ type: "AboutPage", slug: "partners", locale: "en" });
330
+
331
+ expect(fetchDirectory).toHaveBeenCalledWith("k", "/partners", "en");
332
+ });
333
+
334
+ it("makes no read with neither variable set", async () => {
335
+ setEnv(PUBLIC_NAME, undefined);
336
+ setEnv(FALLBACK_NAME, undefined);
337
+ path = "/en/partners";
338
+
339
+ expect(
340
+ await keylessDepsExtension().appendix!({ type: "AboutPage", slug: "partners", locale: "en" }),
341
+ ).toBeNull();
342
+ expect(fetchDirectory).toHaveBeenCalledTimes(0);
343
+ });
344
+ });
345
+
297
346
  describe("a failed read renders the page as today", () => {
298
347
  let warn: jest.SpyInstance;
299
348
  beforeEach(() => {
@@ -1,7 +1,11 @@
1
1
  // oss: false
2
2
  /**
3
3
  * Tests for enabledMeetingSlugs (#6183 S9b D5) — the D4-resolving read both
4
- * /meet pages share.
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).
5
9
  */
6
10
  const mockMeetingTypes = jest.fn();
7
11
  jest.mock("@working-theory/api-client", () => ({
@@ -9,6 +13,14 @@ jest.mock("@working-theory/api-client", () => ({
9
13
  schedule: { meetingTypes: (...args: unknown[]) => mockMeetingTypes(...args) },
10
14
  }));
11
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
+
12
24
  import { enabledMeetingSlugs } from "../meeting-slugs";
13
25
 
14
26
  describe("enabledMeetingSlugs", () => {
@@ -16,6 +28,7 @@ describe("enabledMeetingSlugs", () => {
16
28
 
17
29
  beforeEach(() => {
18
30
  mockMeetingTypes.mockReset();
31
+ mockTopology.value = "hub";
19
32
  });
20
33
 
21
34
  afterEach(() => {
@@ -70,3 +83,38 @@ describe("enabledMeetingSlugs", () => {
70
83
  await expect(enabledMeetingSlugs()).resolves.toEqual([]);
71
84
  });
72
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
+ });
@@ -12,6 +12,7 @@
12
12
 
13
13
  import { handleRelay, RELAY_ROUTES } from "../relay";
14
14
  import { __resetRelayRateLimit, __relayRateLimitBucketCount } from "../relay";
15
+ import { TOPOLOGY } from "../topology.generated";
15
16
 
16
17
  const HUB = "https://hub.example";
17
18
  const SITE = "https://spoke.example";
@@ -513,6 +514,38 @@ describe("hardening — what the site serves, and a missing hub address", () =>
513
514
  expect(res.status).toBe(201);
514
515
  expect(fetchMock).toHaveBeenCalledTimes(1);
515
516
  });
517
+
518
+ // #7874 D12 — the fallback reads the browser API origin seam. The
519
+ // committed seam is the hub render, so this case runs it unmocked.
520
+ // #8060 — hub-only: a spoke render of the seam reads no
521
+ // NEXT_PUBLIC_AILK_API_URL, so a spoke scaffold skips this case.
522
+ (TOPOLOGY === "hub" ? it : it.skip)(
523
+ "on a hub, NEXT_PUBLIC_AILK_API_URL alone still counts as a hub address",
524
+ async () => {
525
+ delete process.env.AILK_API_URL;
526
+ process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
527
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
528
+ expect(res.status).toBe(201);
529
+ expect(fetchMock).toHaveBeenCalledTimes(1);
530
+ },
531
+ );
532
+
533
+ it("on a spoke (the seam's spoke render), that fallback is skipped", async () => {
534
+ delete process.env.AILK_API_URL;
535
+ process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
536
+ let res: Response | undefined;
537
+ await jest.isolateModulesAsync(async () => {
538
+ jest.doMock("../api-origin.generated", () => ({
539
+ browserApiOrigin: (): string | undefined => undefined,
540
+ }));
541
+ const spoke = await import("../relay");
542
+ res = await spoke.handleRelay(relayRequest("/v1/leads/submit"));
543
+ });
544
+ if (res === undefined) throw new Error("the spoke relay did not answer");
545
+ expect(res.status).toBe(502);
546
+ expect(await errorCode(res)).toBe("relay_unreachable");
547
+ expect(fetchMock).not.toHaveBeenCalled();
548
+ });
516
549
  });
517
550
  });
518
551
 
@@ -11,6 +11,7 @@
11
11
  * sets `Origin`.
12
12
  */
13
13
  import { forwardedCookieInit } from "../server-api";
14
+ import { TOPOLOGY } from "../topology.generated";
14
15
 
15
16
  const mockCookies = jest.fn();
16
17
  const mockHeaders = jest.fn();
@@ -27,35 +28,43 @@ describe("forwardedCookieInit", () => {
27
28
  mockHeaders.mockReset();
28
29
  });
29
30
 
30
- it("forwards cookie + origin when the incoming request carries an Origin header", async () => {
31
- mockCookies.mockResolvedValue({
32
- toString: () => "better-auth.session_token=abc",
33
- });
34
- mockHeaders.mockResolvedValue(
35
- new Headers({ origin: "https://app.example.com" }),
36
- );
37
-
38
- const init = await forwardedCookieInit();
39
-
40
- expect(init).toEqual({
41
- headers: {
42
- cookie: "better-auth.session_token=abc",
43
- origin: "https://app.example.com",
44
- },
45
- });
46
- });
31
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
32
+ (TOPOLOGY === "hub" ? it : it.skip)(
33
+ "forwards cookie + origin when the incoming request carries an Origin header",
34
+ async () => {
35
+ mockCookies.mockResolvedValue({
36
+ toString: () => "better-auth.session_token=abc",
37
+ });
38
+ mockHeaders.mockResolvedValue(
39
+ new Headers({ origin: "https://app.example.com" }),
40
+ );
47
41
 
48
- it("forwards cookie alone — no origin key at all — when Origin is absent", async () => {
49
- mockCookies.mockResolvedValue({
50
- toString: () => "better-auth.session_token=abc",
51
- });
52
- mockHeaders.mockResolvedValue(new Headers());
42
+ const init = await forwardedCookieInit();
53
43
 
54
- const init = await forwardedCookieInit();
44
+ expect(init).toEqual({
45
+ headers: {
46
+ cookie: "better-auth.session_token=abc",
47
+ origin: "https://app.example.com",
48
+ },
49
+ });
50
+ },
51
+ );
55
52
 
56
- expect(init).toEqual({
57
- headers: { cookie: "better-auth.session_token=abc" },
58
- });
59
- expect(init.headers).not.toHaveProperty("origin");
60
- });
53
+ // #8060 — hub-only: a spoke render of the seam does not run this case.
54
+ (TOPOLOGY === "hub" ? it : it.skip)(
55
+ "forwards cookie alone — no origin key at all — when Origin is absent",
56
+ async () => {
57
+ mockCookies.mockResolvedValue({
58
+ toString: () => "better-auth.session_token=abc",
59
+ });
60
+ mockHeaders.mockResolvedValue(new Headers());
61
+
62
+ const init = await forwardedCookieInit();
63
+
64
+ expect(init).toEqual({
65
+ headers: { cookie: "better-auth.session_token=abc" },
66
+ });
67
+ expect(init.headers).not.toHaveProperty("origin");
68
+ },
69
+ );
61
70
  });