create-ailk 0.15.0 → 0.16.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 (127) 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 +42 -0
  29. package/dist/spoke-checkout.js +359 -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 +449 -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 +15 -0
  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 +27 -0
  93. package/templates/apps/web/lib/account-config.ts +130 -1
  94. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  95. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  96. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  97. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  98. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  99. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  100. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  101. package/templates/apps/web/lib/listings-config.ts +31 -0
  102. package/templates/apps/web/lib/listings.tsx +7 -3
  103. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  104. package/templates/apps/web/lib/next-path.ts +35 -0
  105. package/templates/apps/web/lib/relay.ts +3 -1
  106. package/templates/apps/web/lib/server-api.ts +11 -1
  107. package/templates/apps/web/lib/topology.generated.ts +15 -5
  108. package/templates/apps/web/messages/en.json +32 -3
  109. package/templates/database/CHANGELOG.md +26 -0
  110. package/templates/database/__tests__/agents.db.test.ts +25 -5
  111. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  112. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  113. package/templates/database/__tests__/person.db.test.ts +584 -0
  114. package/templates/database/auth/schema.prisma +3 -2
  115. package/templates/database/config/schema.prisma +13 -0
  116. package/templates/database/inbox/schema.prisma +98 -0
  117. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  118. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  119. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  120. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  121. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  122. package/templates/database/package.json +1 -1
  123. package/templates/database/scripts/person-backfill.sql +132 -0
  124. package/templates/database/tenancy.ts +53 -0
  125. package/templates/tools/check-declared-env.ts +37 -0
  126. package/templates/tools/declared-env.ts +1163 -0
  127. package/versions.json +35 -35
@@ -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
+ });
@@ -513,6 +513,33 @@ describe("hardening — what the site serves, and a missing hub address", () =>
513
513
  expect(res.status).toBe(201);
514
514
  expect(fetchMock).toHaveBeenCalledTimes(1);
515
515
  });
516
+
517
+ // #7874 D12 — the fallback reads the browser API origin seam. The
518
+ // committed seam is the hub render, so this case runs it unmocked.
519
+ it("on a hub, NEXT_PUBLIC_AILK_API_URL alone still counts as a hub address", async () => {
520
+ delete process.env.AILK_API_URL;
521
+ process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
522
+ const res = await handleRelay(relayRequest("/v1/leads/submit"));
523
+ expect(res.status).toBe(201);
524
+ expect(fetchMock).toHaveBeenCalledTimes(1);
525
+ });
526
+
527
+ it("on a spoke (the seam's spoke render), that fallback is skipped", async () => {
528
+ delete process.env.AILK_API_URL;
529
+ process.env.NEXT_PUBLIC_AILK_API_URL = HUB;
530
+ let res: Response | undefined;
531
+ await jest.isolateModulesAsync(async () => {
532
+ jest.doMock("../api-origin.generated", () => ({
533
+ browserApiOrigin: (): string | undefined => undefined,
534
+ }));
535
+ const spoke = await import("../relay");
536
+ res = await spoke.handleRelay(relayRequest("/v1/leads/submit"));
537
+ });
538
+ if (res === undefined) throw new Error("the spoke relay did not answer");
539
+ expect(res.status).toBe(502);
540
+ expect(await errorCode(res)).toBe("relay_unreachable");
541
+ expect(fetchMock).not.toHaveBeenCalled();
542
+ });
516
543
  });
517
544
  });
518
545
 
@@ -32,22 +32,41 @@
32
32
  // failure rejects — a 401/403 for a verified email included — so the area
33
33
  // shows an error row with Retry instead of an empty Inbox (D12). Scheduled
34
34
  // (the old Upcoming) stays empty in the base area; a module fills it.
35
+ //
36
+ // The member area's spoke leg (#7920) — the client's own records on the hub,
37
+ // read by a spoke that signs its clients in through the hub (#7877, #7894):
38
+ //
39
+ // - Inbox kinds `piece` (`GET /v1/member/pieces`) and `message`
40
+ // (`GET /v1/member/messages`). A message with a file has a file action to
41
+ // this site's own member file read, `account/inbox/message/[id]/file`,
42
+ // which re-serves the hub's bytes — the browser holds no hub credential.
43
+ // - For-you source `piece-needs-client` — each piece waiting on the client,
44
+ // with an Open action to its Inbox row. It shares the pieces read.
45
+ //
46
+ // The hub admits these reads only with the client's hub credential, which
47
+ // `ctx.init` carries as `Authorization` on such a spoke and nowhere else. On a
48
+ // cookie-session site (a hub, or a site with its own sign-in) there is none,
49
+ // so the three make no call and load `[]` — inert, never an error row.
35
50
 
36
51
  import {
37
52
  flowCheckouts,
53
+ member,
38
54
  subscription,
39
55
  waitlistSignups,
40
56
  type FlowCheckoutMeItem,
41
57
  type WaitlistSignupMeResponse,
42
58
  } from "@working-theory/api-client";
43
59
  import type {
60
+ AccountContext,
44
61
  AccountForYouSource,
45
62
  AccountInboxKind,
46
63
  AccountSettingsSection,
47
64
  AccountUser,
48
65
  } from "@working-theory/templates/account";
49
66
  import { localeHref } from "@working-theory/ui";
67
+ import type { MemberMessage, MemberPiece } from "@working-theory/validation";
50
68
  import { formatMoney, formatNumber } from "@working-theory/web-i18n";
69
+ import { cache } from "react";
51
70
 
52
71
  import { unwrapApiResult } from "./server-api";
53
72
 
@@ -168,6 +187,92 @@ const purchase: AccountInboxKind<FlowCheckoutMeItem> = {
168
187
  },
169
188
  };
170
189
 
190
+ // ── Member Inbox kinds (#7920) ───────────────────────────────────────────────
191
+
192
+ /** Whether `init` carries the client's hub credential (see the header). */
193
+ function hasHubCredential({ init }: AccountContext): boolean {
194
+ return Object.keys(init.headers).some((name) => name.toLowerCase() === "authorization");
195
+ }
196
+
197
+ /** One pieces read per request — the `piece` kind and the For-you source share it. */
198
+ const memberPieces = cache((init: AccountContext["init"]) => member.pieces(init));
199
+
200
+ async function loadPieces(ctx: AccountContext): Promise<MemberPiece[]> {
201
+ if (!hasHubCredential(ctx)) return [];
202
+ return (await read(memberPieces(ctx.init), ctx.user))?.items ?? [];
203
+ }
204
+
205
+ const piece: AccountInboxKind<MemberPiece> = {
206
+ kind: "piece",
207
+ label: "pieces.heading",
208
+ section: "updates",
209
+ async load(ctx) {
210
+ return (await loadPieces(ctx)).map((p) => ({
211
+ id: p.id,
212
+ title: p.title,
213
+ at: p.updatedAt,
214
+ data: p,
215
+ }));
216
+ },
217
+ renderDetail({ data }, { locale, t }) {
218
+ return {
219
+ status: data.stage,
220
+ ...(data.needsClient ? { lead: t("pieces.needsClient") } : {}),
221
+ fields: {
222
+ heading: t("pieces.heading"),
223
+ items: [
224
+ { label: t("pieces.stage"), value: data.stage },
225
+ { label: t("pieces.createdAt"), value: formatWhen(data.createdAt, locale) },
226
+ { label: t("pieces.updatedAt"), value: formatWhen(data.updatedAt, locale) },
227
+ ],
228
+ },
229
+ };
230
+ },
231
+ };
232
+
233
+ const message: AccountInboxKind<MemberMessage> = {
234
+ kind: "message",
235
+ label: "messages.heading",
236
+ section: "updates",
237
+ async load(ctx) {
238
+ if (!hasHubCredential(ctx)) return [];
239
+ const messages = (await read(member.messages(ctx.init), ctx.user))?.items ?? [];
240
+ return messages.map((m) => ({
241
+ id: m.id,
242
+ title: m.subject,
243
+ at: m.createdAt,
244
+ data: m,
245
+ }));
246
+ },
247
+ renderDetail({ data }, { locale, t }) {
248
+ return {
249
+ lead: data.body,
250
+ ...(data.file
251
+ ? {
252
+ actions: [
253
+ {
254
+ key: "file",
255
+ label: t("messages.download", { filename: data.file.filename }),
256
+ variant: "default" as const,
257
+ href: localeHref(
258
+ locale,
259
+ `/account/inbox/message/${encodeURIComponent(data.id)}/file`,
260
+ ),
261
+ },
262
+ ],
263
+ }
264
+ : {}),
265
+ fields: {
266
+ heading: t("messages.heading"),
267
+ items: [
268
+ { label: t("messages.sentAt"), value: formatWhen(data.createdAt, locale) },
269
+ ...(data.file ? [{ label: t("messages.file"), value: data.file.filename }] : []),
270
+ ],
271
+ },
272
+ };
273
+ },
274
+ };
275
+
171
276
  // ── Base For-you sources (D9 as amended, D8a) ────────────────────────────────
172
277
 
173
278
  const emailUnverified: AccountForYouSource = {
@@ -206,10 +311,33 @@ const paymentDue: AccountForYouSource = {
206
311
  },
207
312
  };
208
313
 
314
+ // ── Member For-you source (#7920) ────────────────────────────────────────────
315
+
316
+ const pieceNeedsClient: AccountForYouSource = {
317
+ id: "piece-needs-client",
318
+ async load(ctx) {
319
+ const { locale, t } = ctx;
320
+ return (await loadPieces(ctx))
321
+ .filter((p) => p.needsClient)
322
+ .map((p) => ({
323
+ id: `piece:${p.id}`,
324
+ type: { id: "piece", label: t("pieces.type"), tone: "needs-you" },
325
+ title: p.title,
326
+ // The read carries no time the piece began waiting — its last update.
327
+ time: { kind: "at", at: p.updatedAt },
328
+ action: {
329
+ kind: "open",
330
+ href: localeHref(locale, `/account/inbox/piece/${encodeURIComponent(p.id)}`),
331
+ },
332
+ dismissible: true,
333
+ }));
334
+ },
335
+ };
336
+
209
337
  // ── Contributions ─────────────────────────────────────────────────────────────
210
338
 
211
339
  /** Inbox item kinds, in load order. A module appends its own. */
212
- export const accountInboxKinds: AccountInboxKind[] = [result, purchase];
340
+ export const accountInboxKinds: AccountInboxKind[] = [result, purchase, piece, message];
213
341
 
214
342
  /** User-settings sections a module adds after the base ones. None in the base area. */
215
343
  export const accountSettingsSections: AccountSettingsSection[] = [];
@@ -218,4 +346,5 @@ export const accountSettingsSections: AccountSettingsSection[] = [];
218
346
  export const accountForYouSources: AccountForYouSource[] = [
219
347
  emailUnverified,
220
348
  paymentDue,
349
+ pieceNeedsClient,
221
350
  ];
@@ -0,0 +1,11 @@
1
+ // api-origin.generated.ts — the browser-facing API origin this site's server reads.
2
+ //
3
+ // GENERATED by create-ailk for this site's topology (#7874). Do not edit:
4
+ // every scaffold rewrites it. A hub reads NEXT_PUBLIC_AILK_API_URL as a
5
+ // fallback API origin; a spoke (`--topology spoke`) has no browser-facing
6
+ // API origin — its hub origin is server-only (AILK_API_URL) — so its render
7
+ // reads no environment variable.
8
+
9
+ export function browserApiOrigin(): string | undefined {
10
+ return process.env.NEXT_PUBLIC_AILK_API_URL;
11
+ }