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
@@ -0,0 +1,205 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The member file read, re-served by the spoke (#7920; SEC-7877-D) —
7
+ * `GET /[locale]/account/inbox/message/:id/file`.
8
+ *
9
+ * A message's file action links here: the browser holds neither the hub
10
+ * credential (sealed in the `hub-session` cookie) nor the spoke key, so the
11
+ * spoke's server reads `GET /v1/member/messages/:id/file` with
12
+ * `forwardedCookieInit()` and streams the bytes back. The api-client's
13
+ * `member.messageFile` is the one external boundary mocked; the seal, the
14
+ * cookie read and the route's decisions are real. Covers:
15
+ * - a 404 unless the web seam's `SIGN_IN_PROVIDER` is `"hub"`, no hub call;
16
+ * - the read carries the signed-in caller's own credential, and the bytes
17
+ * come back with all five hub headers unchanged;
18
+ * - a hub answer missing any of the five is refused 502 — never bytes on
19
+ * this origin without them;
20
+ * - refusals map to a status with no body and `private, no-store`.
21
+ */
22
+
23
+ const mockMessageFile = jest.fn();
24
+ jest.mock("@working-theory/api-client", () => ({
25
+ __esModule: true,
26
+ member: { messageFile: (...args: unknown[]) => mockMessageFile(...args) },
27
+ }));
28
+
29
+ const mockProvider: { value: "site" | "hub" | "none" } = { value: "hub" };
30
+ // eslint-disable-next-line no-restricted-syntax -- #507: the sign-in provider is a generated constant (#7894 D8), not a runtime input; the seam IS the boundary between a hub and a hub-mode spoke build, and no env or HTTP surface selects it.
31
+ jest.mock("../../../../../../../../../lib/topology.generated", () => ({
32
+ __esModule: true,
33
+ get SIGN_IN_PROVIDER() {
34
+ return mockProvider.value;
35
+ },
36
+ }));
37
+
38
+ const mockHeaders = jest.fn();
39
+ jest.mock("next/headers", () => ({
40
+ __esModule: true,
41
+ headers: (...args: unknown[]) => mockHeaders(...args),
42
+ cookies: jest.fn().mockResolvedValue({ toString: () => "" }),
43
+ }));
44
+
45
+ import { seal } from "../../../../../../../../../lib/hub-session/seal";
46
+ import { GET } from "../route";
47
+
48
+ const ENV = process.env;
49
+ const SITE = "https://spoke.example.com";
50
+ const SESSION_SECRET = "s".repeat(43);
51
+ const COOKIE = "__Host-ailk-hub-session";
52
+ const CREDENTIAL = `ailk_hst_${"t".repeat(43)}`;
53
+ const ID = "intx_01920000-0000-7000-8000-000000000004";
54
+
55
+ const FILE_HEADERS = {
56
+ contentType: "application/pdf",
57
+ contentDisposition: 'attachment; filename="brand-guide.pdf"',
58
+ contentTypeOptions: "nosniff",
59
+ contentSecurityPolicy: "default-src 'none'; sandbox",
60
+ cacheControl: "private, no-store",
61
+ };
62
+
63
+ function fileResult(headers: Partial<Record<keyof typeof FILE_HEADERS, string | null>> = {}) {
64
+ return {
65
+ ok: true,
66
+ body: {
67
+ stream: new Response("%PDF-1.7 bytes").body,
68
+ headers: { ...FILE_HEADERS, ...headers },
69
+ },
70
+ };
71
+ }
72
+
73
+ function signedIn(): void {
74
+ const sealed = seal(
75
+ {
76
+ v: 1,
77
+ accessToken: CREDENTIAL,
78
+ expiresAt: Date.now() + 60_000,
79
+ user: { name: "Ada", email: "ada@example.com", emailVerified: true, createdAt: "2026-09-01T00:00:00.000Z" },
80
+ },
81
+ SESSION_SECRET,
82
+ "session",
83
+ COOKIE,
84
+ );
85
+ mockHeaders.mockResolvedValue(new Headers({ cookie: `${COOKIE}=${sealed}` }));
86
+ }
87
+
88
+ function get(id = ID): Promise<Response> {
89
+ return GET(new Request(`${SITE}/account/inbox/message/${id}/file`), {
90
+ params: Promise.resolve({ locale: "en", id }),
91
+ });
92
+ }
93
+
94
+ beforeEach(() => {
95
+ process.env = {
96
+ ...ENV,
97
+ AILK_HUB_SIGN_IN_URL: "https://hub.example.com",
98
+ AILK_HUB_CLIENT_ID: "spoke_0001",
99
+ AILK_HUB_CLIENT_SECRET: `ailk_hcs_${"c".repeat(43)}`,
100
+ AILK_HUB_SESSION_SECRET: SESSION_SECRET,
101
+ NEXT_PUBLIC_BASE_URL: SITE,
102
+ };
103
+ mockProvider.value = "hub";
104
+ mockHeaders.mockReset().mockResolvedValue(new Headers());
105
+ mockMessageFile.mockReset().mockResolvedValue(fileResult());
106
+ });
107
+
108
+ afterAll(() => {
109
+ process.env = ENV;
110
+ });
111
+
112
+ describe("the member file read on a spoke that signs in through its hub", () => {
113
+ it("reads the file with the caller's own credential and re-serves it with all five headers unchanged", async () => {
114
+ signedIn();
115
+ const res = await get();
116
+ expect(mockMessageFile).toHaveBeenCalledWith(ID, {
117
+ headers: { authorization: `Bearer ${CREDENTIAL}` },
118
+ });
119
+ expect(res.status).toBe(200);
120
+ expect(res.headers.get("content-type")).toBe(FILE_HEADERS.contentType);
121
+ expect(res.headers.get("content-disposition")).toBe(FILE_HEADERS.contentDisposition);
122
+ expect(res.headers.get("x-content-type-options")).toBe(FILE_HEADERS.contentTypeOptions);
123
+ expect(res.headers.get("content-security-policy")).toBe(FILE_HEADERS.contentSecurityPolicy);
124
+ expect(res.headers.get("cache-control")).toBe(FILE_HEADERS.cacheControl);
125
+ await expect(res.text()).resolves.toBe("%PDF-1.7 bytes");
126
+ });
127
+
128
+ it.each(Object.keys(FILE_HEADERS) as (keyof typeof FILE_HEADERS)[])(
129
+ "refuses 502 with no bytes when the hub's answer lacks %s",
130
+ async (name) => {
131
+ signedIn();
132
+ const result = fileResult({ [name]: null });
133
+ const cancel = jest.spyOn(result.body.stream as ReadableStream, "cancel");
134
+ mockMessageFile.mockResolvedValue(result);
135
+ const refused = await get();
136
+ expect(refused.status).toBe(502);
137
+ expect(cancel).toHaveBeenCalled();
138
+ expect(refused.headers.get("cache-control")).toBe("private, no-store");
139
+ expect(refused.headers.get("content-disposition")).toBeNull();
140
+ await expect(refused.text()).resolves.toBe("");
141
+ },
142
+ );
143
+
144
+ it("signed out, the read goes with no credential and its refusal answers 401", async () => {
145
+ mockMessageFile.mockResolvedValue({
146
+ ok: false,
147
+ status: 0,
148
+ body: { ok: false, error: { code: "unauthorized", message: "No usable credential" } },
149
+ });
150
+ const res = await get();
151
+ expect(mockMessageFile).toHaveBeenCalledWith(ID, { headers: {} });
152
+ expect(res.status).toBe(401);
153
+ expect(res.headers.get("cache-control")).toBe("private, no-store");
154
+ await expect(res.text()).resolves.toBe("");
155
+ });
156
+
157
+ it.each([
158
+ [404, 404],
159
+ [401, 401],
160
+ [403, 403],
161
+ [429, 429],
162
+ [500, 502],
163
+ [503, 502],
164
+ ])("a hub %i answers %i, with no body", async (hubStatus, status) => {
165
+ signedIn();
166
+ mockMessageFile.mockResolvedValue({
167
+ ok: false,
168
+ status: hubStatus,
169
+ body: { ok: false, error: { code: "x", message: "x" } },
170
+ });
171
+ const res = await get();
172
+ expect(res.status).toBe(status);
173
+ expect(res.headers.get("cache-control")).toBe("private, no-store");
174
+ await expect(res.text()).resolves.toBe("");
175
+ });
176
+
177
+ it("an API base the api-client refuses (insecure_transport) answers 502", async () => {
178
+ signedIn();
179
+ mockMessageFile.mockResolvedValue({
180
+ ok: false,
181
+ status: 0,
182
+ body: { ok: false, error: { code: "insecure_transport", message: "x" } },
183
+ });
184
+ expect((await get()).status).toBe(502);
185
+ });
186
+
187
+ it("a hub call that throws (network failure, a redirect under redirect: error) answers 502 with no body", async () => {
188
+ signedIn();
189
+ mockMessageFile.mockRejectedValue(new TypeError("fetch failed"));
190
+ const res = await get();
191
+ expect(res.status).toBe(502);
192
+ expect(res.headers.get("cache-control")).toBe("private, no-store");
193
+ await expect(res.text()).resolves.toBe("");
194
+ });
195
+ });
196
+
197
+ describe("on a site that does not sign in through a hub", () => {
198
+ it.each(["site", "none"] as const)("SIGN_IN_PROVIDER %s answers 404 and calls nothing", async (provider) => {
199
+ mockProvider.value = provider;
200
+ signedIn();
201
+ const res = await get();
202
+ expect(res.status).toBe(404);
203
+ expect(mockMessageFile).not.toHaveBeenCalled();
204
+ });
205
+ });
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The member file read, re-served (#7920) — `GET /[locale]/account/inbox/message/:id/file`.
3
+ *
4
+ * A message's file action (the `message` Inbox kind, `lib/account-config.ts`)
5
+ * links here. The browser holds neither the client's hub credential (sealed in
6
+ * the `hub-session` cookie) nor this site's spoke key, so this route reads the
7
+ * hub's `GET /v1/member/messages/:id/file` with `forwardedCookieInit()` — the
8
+ * caller's own credential, so a client only ever gets their own file — and
9
+ * streams the bytes back unread.
10
+ *
11
+ * SEC-7877-D (the obligation in `@working-theory/api-client`'s `member.ts`):
12
+ * the bytes go out with all five hub headers UNCHANGED — `Content-Type`,
13
+ * `Content-Disposition` (attachment), `X-Content-Type-Options: nosniff`, the
14
+ * sandbox `Content-Security-Policy` and `Cache-Control: private, no-store` —
15
+ * so the file is never a document on this origin and never cached on the way.
16
+ * A hub answer missing any of the five is refused 502 rather than served
17
+ * without it.
18
+ *
19
+ * Only a spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
20
+ * #7894) carries the credential; anywhere else this is a 404 with no call.
21
+ * A refusal has no body: no credential (signed out) is a 401, a hub 4xx keeps
22
+ * its status, and a hub 5xx, a transport the api-client refuses or a hub call
23
+ * that throws is a 502.
24
+ */
25
+ import { member, type MemberFileHeaders } from "@working-theory/api-client";
26
+
27
+ import { forwardedCookieInit } from "../../../../../../../../lib/server-api";
28
+ import { SIGN_IN_PROVIDER } from "../../../../../../../../lib/topology.generated";
29
+
30
+ interface Context {
31
+ params: Promise<{ locale: string; id: string }>;
32
+ }
33
+
34
+ /** The response header each of the five hub headers goes out under. */
35
+ const FILE_HEADER_NAMES: Record<keyof MemberFileHeaders, string> = {
36
+ contentType: "Content-Type",
37
+ contentDisposition: "Content-Disposition",
38
+ contentTypeOptions: "X-Content-Type-Options",
39
+ contentSecurityPolicy: "Content-Security-Policy",
40
+ cacheControl: "Cache-Control",
41
+ };
42
+
43
+ function refusal(status: number): Response {
44
+ return new Response(null, { status, headers: { "Cache-Control": "private, no-store" } });
45
+ }
46
+
47
+ /** The refusal's status, from the api-client's `{ ok: false, status, body }`. */
48
+ function refusalStatus(status: number, body: unknown): number {
49
+ if (status === 0) {
50
+ const code = (body as { error?: { code?: unknown } } | null)?.error?.code;
51
+ return code === "unauthorized" ? 401 : 502;
52
+ }
53
+ return status >= 500 ? 502 : status;
54
+ }
55
+
56
+ // platform-decisions-waiver: api-versioning — a page-scoped download link on the site's own account area, not an API contract; it re-serves the versioned hub route /v1/member/messages/:id/file
57
+ export async function GET(_request: Request, { params }: Context): Promise<Response> {
58
+ if (SIGN_IN_PROVIDER !== "hub") return refusal(404);
59
+ const { id } = await params;
60
+
61
+ // The api-client throws, rather than answering `{ ok: false }`, on a
62
+ // network failure or a redirect — that is a 502 too, never Next's own 500.
63
+ let res: Awaited<ReturnType<typeof member.messageFile>>;
64
+ try {
65
+ res = await member.messageFile(id, await forwardedCookieInit());
66
+ } catch {
67
+ return refusal(502);
68
+ }
69
+ if (!res.ok) return refusal(refusalStatus(res.status, res.body));
70
+
71
+ const { stream, headers } = res.body;
72
+ const out = new Headers();
73
+ for (const [key, name] of Object.entries(FILE_HEADER_NAMES) as [keyof MemberFileHeaders, string][]) {
74
+ const value = headers[key];
75
+ if (value === null) {
76
+ await stream.cancel();
77
+ return refusal(502);
78
+ }
79
+ out.set(name, value);
80
+ }
81
+ return new Response(stream, { status: 200, headers: out });
82
+ }
@@ -21,6 +21,13 @@
21
21
  * 401/403/404 (the commerce-disabled build answers 404).
22
22
  * - Then any section a module contributes (`accountSettingsSections`).
23
23
  *
24
+ * On a spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
25
+ * #7894 D10) the account belongs to the hub: the name is a read-only row,
26
+ * Security says sign-in happens on the hub, and both the name edit and the
27
+ * deletion request are a link to the hub's own account settings — so a
28
+ * request about a hub account never lands in this spoke's inbox
29
+ * (SEC-7894-11). Billing is absent there because its read answers 404.
30
+ *
24
31
  * No Credentials, Scope, Organizations or Credits section: those are an
25
32
  * agent's or an operator's (§5.12a), never a member's.
26
33
  */
@@ -35,6 +42,8 @@ import type { ReactElement, ReactNode } from "react";
35
42
 
36
43
  import { accountSettingsSections } from "../../../../../lib/account-config";
37
44
  import { getActiveProviderIds } from "../../../../../lib/auth-env";
45
+ import { hubSessionConfig } from "../../../../../lib/hub-session/config";
46
+ import { SIGN_IN_PROVIDER } from "../../../../../lib/topology.generated";
38
47
  import { accountSession, logReadFailure } from "../account-data";
39
48
  import { openBillingPortal } from "../actions";
40
49
 
@@ -95,6 +104,31 @@ const METHOD_KEYS: Record<string, string> = {
95
104
  email: "settings.security.password",
96
105
  };
97
106
 
107
+ /** The hub's own account settings page, on a spoke that signs in through it. */
108
+ function hubSettingsHref(): string | null {
109
+ const config = hubSessionConfig();
110
+ return config === null ? null : new URL("/account/settings", config.hubOrigin).toString();
111
+ }
112
+
113
+ /** A link to the hub's account settings, in place of a control that would act on the spoke. */
114
+ function HubSettingsLink({
115
+ href,
116
+ label,
117
+ testId,
118
+ }: {
119
+ href: string;
120
+ label: string;
121
+ testId: string;
122
+ }): ReactElement {
123
+ return (
124
+ <div>
125
+ <Link href={href} className="inline-flex min-h-6 items-center" data-testid={testId}>
126
+ {label}
127
+ </Link>
128
+ </div>
129
+ );
130
+ }
131
+
98
132
  /** The statuses with a message key; any other renders as the API sent it. */
99
133
  const KNOWN_STATUSES: ReadonlySet<string> = new Set(["trialing", "active", "past_due", "canceled"]);
100
134
 
@@ -140,6 +174,8 @@ export default async function AccountSettingsPage({ params }: Props): Promise<Re
140
174
  const sub = await readSubscription(ctx.init);
141
175
  const showBilling = sub !== null && (sub.status !== null || sub.portalAvailable);
142
176
  const methods = getActiveProviderIds().filter((id) => id in METHOD_KEYS);
177
+ const viaHub = SIGN_IN_PROVIDER === "hub";
178
+ const hubHref = viaHub ? hubSettingsHref() : null;
143
179
 
144
180
  return (
145
181
  <div className="flex max-w-3xl flex-col gap-8 py-8" data-testid="account-settings">
@@ -175,26 +211,49 @@ export default async function AccountSettingsPage({ params }: Props): Promise<Re
175
211
  value: ctx.user.email,
176
212
  "data-testid": "account-settings-email",
177
213
  },
214
+ ...(viaHub && ctx.user.name
215
+ ? [
216
+ {
217
+ label: t("settings.general.name"),
218
+ value: ctx.user.name,
219
+ "data-testid": "account-settings-name",
220
+ },
221
+ ]
222
+ : []),
178
223
  ]}
179
224
  />
180
- <NameForm
181
- initialName={ctx.user.name ?? ""}
182
- labels={{
183
- name: t("settings.general.name"),
184
- placeholder: t("settings.general.namePlaceholder"),
185
- save: t("settings.general.save"),
186
- saving: t("settings.general.saving"),
187
- saved: t("settings.general.saved"),
188
- unsaved: t("settings.general.unsaved"),
189
- clean: t("settings.general.clean"),
190
- required: t("settings.general.nameRequired"),
191
- error: t("settings.general.saveError"),
192
- }}
193
- />
225
+ {viaHub ? (
226
+ hubHref !== null ? (
227
+ <HubSettingsLink
228
+ href={hubHref}
229
+ label={t("settings.general.manageOnHub")}
230
+ testId="account-settings-manage-on-hub"
231
+ />
232
+ ) : null
233
+ ) : (
234
+ <NameForm
235
+ initialName={ctx.user.name ?? ""}
236
+ labels={{
237
+ name: t("settings.general.name"),
238
+ placeholder: t("settings.general.namePlaceholder"),
239
+ save: t("settings.general.save"),
240
+ saving: t("settings.general.saving"),
241
+ saved: t("settings.general.saved"),
242
+ unsaved: t("settings.general.unsaved"),
243
+ clean: t("settings.general.clean"),
244
+ required: t("settings.general.nameRequired"),
245
+ error: t("settings.general.saveError"),
246
+ }}
247
+ />
248
+ )}
194
249
  </Section>
195
250
 
196
251
  <Section id="security" title={t("settings.security.title")} lead={t("settings.security.lead")}>
197
- {methods.length > 0 ? (
252
+ {viaHub ? (
253
+ <Text as="p" variant="body-sm" tone="muted">
254
+ {t("settings.security.viaHub")}
255
+ </Text>
256
+ ) : methods.length > 0 ? (
198
257
  <div className="flex flex-col gap-2">
199
258
  <Text as="p" variant="body-sm" className="font-medium">
200
259
  {t("settings.security.methods")}
@@ -234,20 +293,30 @@ export default async function AccountSettingsPage({ params }: Props): Promise<Re
234
293
  {t("settings.privacy.cookies")}
235
294
  </Link>
236
295
  </div>
237
- <div className="flex flex-col gap-2">
238
- <Text as="p" variant="body-sm" tone="muted">
239
- {t("settings.privacy.deletionLead")}
240
- </Text>
241
- <DeletionRequest
242
- labels={{
243
- action: t("settings.privacy.deletion"),
244
- subject: t("settings.privacy.deletionSubject"),
245
- message: t("settings.privacy.deletionMessage"),
246
- sent: t("settings.privacy.deletionSent"),
247
- error: t("settings.privacy.deletionError"),
248
- }}
249
- />
250
- </div>
296
+ {viaHub ? (
297
+ hubHref !== null ? (
298
+ <HubSettingsLink
299
+ href={hubHref}
300
+ label={t("settings.general.manageOnHub")}
301
+ testId="account-privacy-manage-on-hub"
302
+ />
303
+ ) : null
304
+ ) : (
305
+ <div className="flex flex-col gap-2">
306
+ <Text as="p" variant="body-sm" tone="muted">
307
+ {t("settings.privacy.deletionLead")}
308
+ </Text>
309
+ <DeletionRequest
310
+ labels={{
311
+ action: t("settings.privacy.deletion"),
312
+ subject: t("settings.privacy.deletionSubject"),
313
+ message: t("settings.privacy.deletionMessage"),
314
+ sent: t("settings.privacy.deletionSent"),
315
+ error: t("settings.privacy.deletionError"),
316
+ }}
317
+ />
318
+ </div>
319
+ )}
251
320
  </Section>
252
321
 
253
322
  {showBilling ? (
@@ -21,7 +21,7 @@
21
21
  import { leads, visitorForwardHeaders } from "@working-theory/api-client";
22
22
  import { headers } from "next/headers";
23
23
 
24
- import { auth } from "../../../../auth";
24
+ import { SIGN_IN_PROVIDER, readSession } from "../../../../lib/topology.generated";
25
25
 
26
26
  /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
27
27
  function errorName(err: unknown): string {
@@ -61,7 +61,10 @@ export async function submitSupportRequest(
61
61
  _prev: SupportRequestState,
62
62
  formData: FormData,
63
63
  ): Promise<SupportRequestState> {
64
- const kind = field(formData, "kind") === "deletion" ? "deletion" : "support";
64
+ // A hub sign-in spoke's account belongs to the hub, whose own settings take
65
+ // a deletion request (#7894 D10, SEC-7894-11): here it can only be support.
66
+ const kind =
67
+ field(formData, "kind") === "deletion" && SIGN_IN_PROVIDER !== "hub" ? "deletion" : "support";
65
68
  const subject = field(formData, "subject");
66
69
  const message = field(formData, "message");
67
70
  if (!subject || !message || subject.length > MAX_SUBJECT || message.length > MAX_MESSAGE) {
@@ -70,7 +73,7 @@ export async function submitSupportRequest(
70
73
 
71
74
  try {
72
75
  const requestHeaders = await headers();
73
- const session = await auth.api.getSession({ headers: requestHeaders });
76
+ const session = await readSession(requestHeaders);
74
77
  if (!session) return { kind: "error", reason: "failed" };
75
78
 
76
79
  const body = {
@@ -1,54 +1,29 @@
1
1
  /**
2
2
  * (authed) segment layout — the operator-dashboard session guard (#4665 D9).
3
3
  *
4
- * RSC: reads the active Better Auth session via `auth.api.getSession`
5
- * (mirrors `apps-paid/admin/app/(authed)/layout.tsx`). With no session it
6
- * renders `SignInGate` — the dashboard's own front door — rather than
4
+ * RSC: reads the session through the generated topology seam's `readSession`
5
+ * (#7894 D10) — Better Auth's `auth.api.getSession` on a hub, the sealed
6
+ * hub-session cookie on a spoke that signs in through its hub. With no
7
+ * session it renders the generated gate seam's `SignedOutGate` rather than
7
8
  * redirecting (a `redirect("/sign-in")` would render the visitor's requested
8
- * path only as a query param; rendering the gate in place keeps the URL, and
9
- * the gate itself links to `/sign-in?next=<path>` for the full sign-in
10
- * surface — #4984). This guard reads no public-route auth-requirement flag
11
- * (spec Clarifications) — that flag governs whether PUBLIC marketing routes
9
+ * path only as a query param; rendering the gate in place keeps the URL —
10
+ * #4984). This guard reads no public-route auth-requirement flag (spec
11
+ * Clarifications) — that flag governs whether PUBLIC marketing routes
12
12
  * redirect; tenant signup data is never public, so this gate always applies.
13
13
  *
14
- * The gate's providers are resolved HERE, on the server, and passed down as a
15
- * prop (#7419): `SignInGate` is a client component, and the provider env
16
- * `getActiveProviderIds()` reads is server-only — read in the browser it is
17
- * always empty, which made every gated page claim sign-in was not configured.
18
- *
19
- * When the email sign-in link is on (#7435 § D9 — the same
20
- * `getActiveProviderIds()` predicate `auth.ts` registers the plugin on), the
21
- * layout resolves `/sign-in`'s card copy here too and passes it as the gate's
22
- * `emailLink` prop; otherwise it passes nothing and the gate renders as before.
23
- *
24
- * On an account path (#7430, `isAccountPath` on the forwarded `x-pathname`)
25
- * the visitor is a member, not an operator: the gate reads the member-facing
26
- * `account.gate` copy instead of `waitlistDashboard.gate` (which asks for
27
- * provider env vars "to enable operator access"). When no sign-in method is
28
- * on, the layout renders that member gate itself so its headline is a real
29
- * `<h1>` — `SignInGate`'s not-configured branch renders `EmptyState`, whose
30
- * headline is a `<p>` — and its one action leads back to the site rather than
31
- * to a sign-in page that has nothing to offer. An absent `x-pathname` keeps
32
- * the operator copy, the same fail-safe direction as the locale layout's
33
- * account branch.
14
+ * What the gate renders is the seam's (`signed-out-gate.generated.tsx`): on a
15
+ * hub, the operator `SignInGate` or, on an account path, the member gate
16
+ * (#7419, #7430, #7435); on a hub sign-in spoke, the hub-session gate. This
17
+ * layout therefore imports neither `auth.ts` nor the `(auth)` group, so a
18
+ * spoke that keeps its account area keeps this layout too (spec 7894 D9).
34
19
  */
35
20
 
36
- import { localeHref } from "@working-theory/ui";
37
- import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
38
- import type { AuthPanelProvider } from "@working-theory/ui/sections";
39
- import { readLocales } from "@working-theory/web-i18n";
40
21
  import { headers } from "next/headers";
41
- import { getTranslations } from "next-intl/server";
42
22
  import type { ReactElement, ReactNode } from "react";
43
23
 
44
- import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
45
- import { auth } from "../../../auth";
46
- import { getActiveProviderIds } from "../../../lib/auth-env";
47
- import { isAccountPath } from "../../../lib/auth-routes";
48
-
49
- import { SignInGate } from "./sign-in-gate";
24
+ import { readSession } from "../../../lib/topology.generated";
50
25
 
51
- const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
26
+ import { SignedOutGate } from "./signed-out-gate.generated";
52
27
 
53
28
  interface Props {
54
29
  children: ReactNode;
@@ -61,68 +36,11 @@ export default async function AuthedLayout({
61
36
  }: Props): Promise<ReactElement> {
62
37
  const { locale } = await params;
63
38
  const requestHeaders = await headers();
64
- const session = await auth.api.getSession({ headers: requestHeaders });
65
-
66
- if (!session) {
67
- // No `x-pathname`, no locale read: a render outside the middleware keeps
68
- // the operator gate without touching the locale config.
69
- const pathname = requestHeaders.get("x-pathname");
70
- const isAccount =
71
- pathname !== null && isAccountPath(pathname, readLocales());
72
- const t = await getTranslations({
73
- locale,
74
- namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
75
- });
76
- const active = getActiveProviderIds();
77
- const emailLink = active.includes("magic-link")
78
- ? { text: await resolveSignInCardText(locale) }
79
- : undefined;
80
- const providers = active.filter((id): id is AuthPanelProvider =>
81
- (SUPPORTED_PROVIDERS as readonly string[]).includes(id),
82
- );
83
- const signInPath = localeHref(locale, "/sign-in");
84
-
85
- if (isAccount && providers.length === 0 && !emailLink) {
86
- return (
87
- <div
88
- className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
89
- data-testid="account-sign-in-gate"
90
- >
91
- <Heading level={1} visual={4}>
92
- {t("emptyHeadline")}
93
- </Heading>
94
- <Text variant="body-sm" tone="muted" className="max-w-xs">
95
- {t("emptyDescription")}
96
- </Text>
97
- {/* With no sign-in method on, /sign-in has nothing for a member
98
- either, so the one action goes back to the site. Outline
99
- action, 44px on a phone and 36px from md up. */}
100
- <Button asChild variant="secondary" className="max-md:min-h-11">
101
- <Link
102
- href={localeHref(locale, "/")}
103
- variant="bare"
104
- data-testid="account-sign-in-gate-home"
105
- >
106
- {t("homeLink")}
107
- </Link>
108
- </Button>
109
- </div>
110
- );
111
- }
39
+ const session = await readSession(requestHeaders);
112
40
 
113
- return (
114
- <SignInGate
115
- labels={{
116
- emptyHeadline: t("emptyHeadline"),
117
- emptyDescription: t("emptyDescription"),
118
- signInLinkLabel: t("signInLink"),
119
- }}
120
- signInPath={signInPath}
121
- providers={providers}
122
- {...(emailLink ? { emailLink } : {})}
123
- />
124
- );
125
- }
41
+ // Awaited, not rendered as an element: the gate is a server function of the
42
+ // request, and the layout returns exactly the element it resolves to.
43
+ if (!session) return SignedOutGate({ locale, requestHeaders });
126
44
 
127
45
  // data-profile="app" puts the Pro-host editor surfaces in this group on
128
46
  // @working-theory/theme's app profile (#5953); tokens.css's own
@@ -131,7 +49,7 @@ export default async function AuthedLayout({
131
49
  // layout returns a fragment and the attribute needs an element.
132
50
  //
133
51
  // Two things about the placement are deliberate. Only the SIGNED-IN branch
134
- // carries it: SignInGate above is the marketing site's sign-in front door,
52
+ // carries it: the gate above is the marketing site's sign-in front door,
135
53
  // not an app surface. And the attribute goes on this nested div rather than
136
54
  // the app's <html> because apps/web's marketing routes share that root and
137
55
  // must stay on the marketing profile — which also makes this half immune to