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
@@ -0,0 +1,262 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The spoke's hub-session module (#7894 D7, D12, D13; spec
7
+ * docs/specs/7894-the-hub-signs-in-its-spec.md AC-5, AC-7, AC-8).
8
+ *
9
+ * Covers the sealed cookie (a value sealed under a key derived from the
10
+ * CLIENT secret never opens — AC-5), the configuration, the session read, the
11
+ * one request-init re-target `forwardedCookieInit()` makes on a hub-mode spoke
12
+ * (AC-7), and the buyer read that never trusts the cookie for a customer id
13
+ * (AC-8). The api-client's `hubSignIn` is the one external boundary mocked.
14
+ */
15
+
16
+ import { createCipheriv, hkdfSync, randomBytes } from "node:crypto";
17
+
18
+ const mockMe = jest.fn();
19
+ jest.mock("@working-theory/api-client", () => ({
20
+ __esModule: true,
21
+ hubSignIn: { me: (...args: unknown[]) => mockMe(...args) },
22
+ }));
23
+
24
+ const mockHeaders = jest.fn();
25
+ jest.mock("next/headers", () => ({
26
+ __esModule: true,
27
+ headers: (...args: unknown[]) => mockHeaders(...args),
28
+ cookies: jest.fn(),
29
+ }));
30
+
31
+ // 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.
32
+ jest.mock("../../topology.generated", () => ({
33
+ __esModule: true,
34
+ SIGN_IN_PROVIDER: "hub",
35
+ }));
36
+
37
+ import { forwardedCookieInit } from "../../server-api";
38
+ import { hubSessionConfig } from "../config";
39
+ import { seal, unseal } from "../seal";
40
+ import {
41
+ type HubSessionPayload,
42
+ hubNextPath,
43
+ hubSessionBuyer,
44
+ hubSessionInit,
45
+ readHubSession,
46
+ sessionCookieName,
47
+ } from "../session";
48
+
49
+ const ENV = process.env;
50
+ const SESSION_SECRET = "s".repeat(43);
51
+ const CLIENT_SECRET = `ailk_hcs_${"c".repeat(43)}`;
52
+ const CREDENTIAL = `ailk_hst_${"t".repeat(43)}`;
53
+ const COOKIE = "ailk-hub-session";
54
+
55
+ const USER = {
56
+ name: "Ada Lovelace",
57
+ email: "ada@example.com",
58
+ emailVerified: true,
59
+ createdAt: "2026-09-01T00:00:00.000Z",
60
+ };
61
+
62
+ function configure(overrides: Record<string, string | undefined> = {}): void {
63
+ process.env = {
64
+ ...ENV,
65
+ AILK_HUB_SIGN_IN_URL: "https://hub.example.com",
66
+ AILK_HUB_CLIENT_ID: "spoke_0001",
67
+ AILK_HUB_CLIENT_SECRET: CLIENT_SECRET,
68
+ AILK_HUB_SESSION_SECRET: SESSION_SECRET,
69
+ NEXT_PUBLIC_BASE_URL: "http://127.0.0.1:3100",
70
+ ...overrides,
71
+ };
72
+ for (const [key, value] of Object.entries(overrides)) {
73
+ if (value === undefined) delete process.env[key];
74
+ }
75
+ }
76
+
77
+ function payload(overrides: Partial<HubSessionPayload> = {}): HubSessionPayload {
78
+ return {
79
+ v: 1,
80
+ accessToken: CREDENTIAL,
81
+ expiresAt: Date.now() + 60_000,
82
+ user: USER,
83
+ ...overrides,
84
+ };
85
+ }
86
+
87
+ function headersWith(value: string, name = COOKIE): Headers {
88
+ return new Headers({ cookie: `other=1; ${name}=${value}` });
89
+ }
90
+
91
+ beforeEach(() => {
92
+ configure();
93
+ mockMe.mockReset();
94
+ mockHeaders.mockReset();
95
+ });
96
+
97
+ afterAll(() => {
98
+ process.env = ENV;
99
+ });
100
+
101
+ describe("seal", () => {
102
+ it("round-trips a value for the same secret, purpose and cookie name", () => {
103
+ const sealed = seal({ a: 1 }, SESSION_SECRET, "session", COOKIE);
104
+ expect(unseal(sealed, SESSION_SECRET, "session", COOKIE)).toEqual({ a: 1 });
105
+ });
106
+
107
+ it("opens nothing under another secret, purpose or cookie name, or altered", () => {
108
+ const sealed = seal({ a: 1 }, SESSION_SECRET, "session", COOKIE);
109
+ expect(unseal(sealed, "x".repeat(43), "session", COOKIE)).toBeNull();
110
+ expect(unseal(sealed, SESSION_SECRET, "flow", COOKIE)).toBeNull();
111
+ expect(unseal(sealed, SESSION_SECRET, "session", "ailk-hub-flow")).toBeNull();
112
+ const flipped = Buffer.from(sealed, "base64url");
113
+ flipped[flipped.length - 1] = (flipped[flipped.length - 1] ?? 0) ^ 1;
114
+ expect(unseal(flipped.toString("base64url"), SESSION_SECRET, "session", COOKIE)).toBeNull();
115
+ expect(unseal("", SESSION_SECRET, "session", COOKIE)).toBeNull();
116
+ });
117
+
118
+ it("uses a fresh IV per seal", () => {
119
+ expect(seal({ a: 1 }, SESSION_SECRET, "session", COOKIE)).not.toBe(
120
+ seal({ a: 1 }, SESSION_SECRET, "session", COOKIE),
121
+ );
122
+ });
123
+ });
124
+
125
+ describe("hubSessionConfig", () => {
126
+ it("reads the four variables and the site origin; the redirect URI is the site's, never a header's", () => {
127
+ expect(hubSessionConfig()).toEqual({
128
+ hubOrigin: "https://hub.example.com",
129
+ clientId: "spoke_0001",
130
+ clientSecret: CLIENT_SECRET,
131
+ sessionSecret: SESSION_SECRET,
132
+ siteOrigin: "http://127.0.0.1:3100",
133
+ redirectUri: "http://127.0.0.1:3100/api/hub-session/callback",
134
+ secure: false,
135
+ });
136
+ });
137
+
138
+ it("is secure on an https site", () => {
139
+ configure({ NEXT_PUBLIC_BASE_URL: "https://spoke.example.com/" });
140
+ expect(hubSessionConfig()).toMatchObject({
141
+ siteOrigin: "https://spoke.example.com",
142
+ secure: true,
143
+ });
144
+ expect(sessionCookieName({ secure: true })).toBe("__Host-ailk-hub-session");
145
+ });
146
+
147
+ it.each([
148
+ ["no hub origin", { AILK_HUB_SIGN_IN_URL: undefined }],
149
+ ["an http hub off loopback", { AILK_HUB_SIGN_IN_URL: "http://hub.example.com" }],
150
+ ["a hub URL with a path", { AILK_HUB_SIGN_IN_URL: "https://hub.example.com/x" }],
151
+ ["no client id", { AILK_HUB_CLIENT_ID: undefined }],
152
+ ["no client secret", { AILK_HUB_CLIENT_SECRET: undefined }],
153
+ ["a short session secret", { AILK_HUB_SESSION_SECRET: "short" }],
154
+ ["an http site off loopback", { NEXT_PUBLIC_BASE_URL: "http://spoke.example.com" }],
155
+ ])("is null with %s", (_label, overrides) => {
156
+ configure(overrides);
157
+ expect(hubSessionConfig()).toBeNull();
158
+ });
159
+ });
160
+
161
+ describe("readHubSession", () => {
162
+ it("reads the person from a sealed, unexpired cookie", async () => {
163
+ const sealed = seal(payload(), SESSION_SECRET, "session", COOKIE);
164
+ await expect(readHubSession(headersWith(sealed))).resolves.toEqual({ user: USER });
165
+ });
166
+
167
+ it("reads null with no cookie, an expired one, or with no configuration", async () => {
168
+ await expect(readHubSession(new Headers())).resolves.toBeNull();
169
+ const expired = seal(payload({ expiresAt: Date.now() - 1 }), SESSION_SECRET, "session", COOKIE);
170
+ await expect(readHubSession(headersWith(expired))).resolves.toBeNull();
171
+ const sealed = seal(payload(), SESSION_SECRET, "session", COOKIE);
172
+ configure({ AILK_HUB_SESSION_SECRET: undefined });
173
+ await expect(readHubSession(headersWith(sealed))).resolves.toBeNull();
174
+ });
175
+
176
+ it("AC-5: a cookie sealed with a key derived from AILK_HUB_CLIENT_SECRET reads as null", async () => {
177
+ // Every way the client secret could have keyed the cookie: the module's own
178
+ // seal with the client secret as the session secret, and a raw HKDF of it.
179
+ const viaSeal = seal(payload(), CLIENT_SECRET, "session", COOKIE);
180
+ await expect(readHubSession(headersWith(viaSeal))).resolves.toBeNull();
181
+
182
+ const key = Buffer.from(hkdfSync("sha256", CLIENT_SECRET, Buffer.alloc(0), "ailk-hub-session/v1", 32));
183
+ const iv = randomBytes(12);
184
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
185
+ cipher.setAAD(Buffer.from(COOKIE));
186
+ const body = Buffer.concat([cipher.update(JSON.stringify(payload())), cipher.final()]);
187
+ const forged = Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url");
188
+ await expect(readHubSession(headersWith(forged))).resolves.toBeNull();
189
+ });
190
+
191
+ it("reads null for a well-sealed value of the wrong shape", async () => {
192
+ const sealed = seal({ v: 2, accessToken: CREDENTIAL }, SESSION_SECRET, "session", COOKIE);
193
+ await expect(readHubSession(headersWith(sealed))).resolves.toBeNull();
194
+ });
195
+ });
196
+
197
+ describe("AC-7 — forwardedCookieInit on a hub-mode spoke", () => {
198
+ it("is exactly the Bearer credential signed in", async () => {
199
+ const sealed = seal(payload(), SESSION_SECRET, "session", COOKIE);
200
+ mockHeaders.mockResolvedValue(headersWith(sealed));
201
+ await expect(forwardedCookieInit()).resolves.toEqual({
202
+ headers: { authorization: `Bearer ${CREDENTIAL}` },
203
+ });
204
+ await expect(hubSessionInit()).resolves.toEqual({
205
+ headers: { authorization: `Bearer ${CREDENTIAL}` },
206
+ });
207
+ });
208
+
209
+ it("is exactly no header signed out — never the visitor's cookie", async () => {
210
+ mockHeaders.mockResolvedValue(new Headers({ cookie: "better-auth.session_token=abc" }));
211
+ await expect(forwardedCookieInit()).resolves.toEqual({ headers: {} });
212
+ });
213
+ });
214
+
215
+ describe("AC-8 — hubSessionBuyer reads the customer live, never from the cookie", () => {
216
+ function request(cookie?: string): Request {
217
+ return new Request("http://127.0.0.1:3100/api/buy", {
218
+ headers: cookie === undefined ? {} : { cookie: `${COOKIE}=${cookie}` },
219
+ });
220
+ }
221
+ const signedIn = (): Request => request(seal(payload(), SESSION_SECRET, "session", COOKIE));
222
+
223
+ it("returns the customer /me vouches for, sending the credential", async () => {
224
+ mockMe.mockResolvedValue({ ok: true, body: { user: USER, billingCustomerId: "cus_known" } });
225
+ await expect(hubSessionBuyer(signedIn())).resolves.toEqual({ customerId: "cus_known" });
226
+ expect(mockMe).toHaveBeenCalledWith({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
227
+ });
228
+
229
+ it("is null with no session, without calling the hub", async () => {
230
+ await expect(hubSessionBuyer(request())).resolves.toBeNull();
231
+ expect(mockMe).not.toHaveBeenCalled();
232
+ });
233
+
234
+ it("is null when /me refuses the credential though the cookie is intact (a revoked credential)", async () => {
235
+ mockMe.mockResolvedValue({ ok: false, status: 401, body: null });
236
+ await expect(hubSessionBuyer(signedIn())).resolves.toBeNull();
237
+ });
238
+
239
+ it("is null when /me names no customer, or the read throws", async () => {
240
+ mockMe.mockResolvedValueOnce({ ok: true, body: { user: USER, billingCustomerId: null } });
241
+ await expect(hubSessionBuyer(signedIn())).resolves.toBeNull();
242
+ mockMe.mockRejectedValueOnce(new Error("network"));
243
+ await expect(hubSessionBuyer(signedIn())).resolves.toBeNull();
244
+ });
245
+ });
246
+
247
+ describe("hubNextPath", () => {
248
+ it.each([
249
+ ["/account", "/account"],
250
+ ["/fr/account/inbox?tab=x", "/fr/account/inbox?tab=x"],
251
+ ["https://evil.example.com/", "/account"],
252
+ ["//evil.example.com", "/account"],
253
+ ["/api/hub-session/sign-out", "/account"],
254
+ ["/api", "/account"],
255
+ ["/en/../api/hub-session/sign-in?next=/account", "/account"],
256
+ ["/%2e%2e/api/hub-session/sign-in", "/account"],
257
+ ["/\u65e5\u672c", "/account"],
258
+ [null, "/account"],
259
+ ])("%s → %s", (raw, expected) => {
260
+ expect(hubNextPath(raw)).toBe(expected);
261
+ });
262
+ });
@@ -0,0 +1,104 @@
1
+ /**
2
+ * The spoke's hub-session configuration (#7894 D7) — sign-in through the hub.
3
+ *
4
+ * A spoke scaffolded with `--hub-sign-in <origin>` signs a visitor in on its
5
+ * hub and keeps the hub-issued credential in a sealed cookie. Everything that
6
+ * decides where that exchange goes is read here, from the server env, once
7
+ * per call:
8
+ *
9
+ * - `AILK_HUB_SIGN_IN_URL` — the hub's WEB origin (where `/sign-in` and
10
+ * `/api/auth/hub/authorize` live);
11
+ * - `AILK_HUB_CLIENT_ID` — this spoke's client id (its `Spoke.id`);
12
+ * - `AILK_HUB_CLIENT_SECRET` — the secret `spokes sign-in-client` printed;
13
+ * - `AILK_HUB_SESSION_SECRET` — this site's own cookie key material (at
14
+ * least 32 characters). It never derives from
15
+ * the client secret (`seal.ts`).
16
+ *
17
+ * The site's own origin is `resolveBaseUrl()` — `NEXT_PUBLIC_BASE_URL`, else
18
+ * the site's `baseUrl` — never a request header, so a forged `Host` cannot move
19
+ * the redirect URI. Any input missing or malformed reads as "not configured"
20
+ * (`null`): the routes then send the visitor back to `/account` and the gate
21
+ * says sign-in is not set up, rather than starting a flow that cannot finish.
22
+ */
23
+
24
+ import { resolveBaseUrl } from "../../src/lib/routes";
25
+
26
+ /** The path the hub redirects back to — the one an operator registers (D3). */
27
+ export const HUB_SESSION_CALLBACK_PATH = "/api/hub-session/callback";
28
+
29
+ /** Hosts an origin may use over plain `http:` (the api-client's loopback set). */
30
+ const LOOPBACK_HOSTS: readonly string[] = ["localhost", "127.0.0.1"];
31
+
32
+ /** The shortest `AILK_HUB_SESSION_SECRET` this module accepts. */
33
+ const MIN_SESSION_SECRET_LENGTH = 32;
34
+
35
+ export interface HubSessionConfig {
36
+ /** The hub's web origin, normalized (no trailing slash). */
37
+ hubOrigin: string;
38
+ clientId: string;
39
+ clientSecret: string;
40
+ sessionSecret: string;
41
+ /** This site's own origin. */
42
+ siteOrigin: string;
43
+ /** `<siteOrigin>/api/hub-session/callback`. */
44
+ redirectUri: string;
45
+ /** Whether the site origin is https — the cookies are then `__Host-` and `Secure`. */
46
+ secure: boolean;
47
+ }
48
+
49
+ /**
50
+ * `raw` as an https origin (or http on a loopback host), else `null`. With
51
+ * `originOnly`, a path other than `/`, a query, a fragment or userinfo is
52
+ * refused rather than dropped.
53
+ */
54
+ function normalizeOrigin(
55
+ raw: string | undefined,
56
+ originOnly: boolean,
57
+ ): string | null {
58
+ if (raw === undefined || raw.trim() === "") return null;
59
+ let url: URL;
60
+ try {
61
+ url = new URL(raw.trim());
62
+ } catch {
63
+ return null;
64
+ }
65
+ const loopback = LOOPBACK_HOSTS.includes(url.hostname);
66
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
67
+ return null;
68
+ }
69
+ if (url.username !== "" || url.password !== "") return null;
70
+ if (
71
+ originOnly &&
72
+ (url.pathname !== "/" || url.search !== "" || url.hash !== "")
73
+ ) {
74
+ return null;
75
+ }
76
+ return url.origin;
77
+ }
78
+
79
+ /** The hub-session configuration, or `null` when this site is not configured. */
80
+ export function hubSessionConfig(): HubSessionConfig | null {
81
+ const hubOrigin = normalizeOrigin(process.env.AILK_HUB_SIGN_IN_URL, true);
82
+ const clientId = process.env.AILK_HUB_CLIENT_ID?.trim() ?? "";
83
+ const clientSecret = process.env.AILK_HUB_CLIENT_SECRET?.trim() ?? "";
84
+ const sessionSecret = process.env.AILK_HUB_SESSION_SECRET?.trim() ?? "";
85
+ const siteOrigin = normalizeOrigin(resolveBaseUrl(), false);
86
+ if (
87
+ hubOrigin === null ||
88
+ siteOrigin === null ||
89
+ clientId === "" ||
90
+ clientSecret === "" ||
91
+ sessionSecret.length < MIN_SESSION_SECRET_LENGTH
92
+ ) {
93
+ return null;
94
+ }
95
+ return {
96
+ hubOrigin,
97
+ clientId,
98
+ clientSecret,
99
+ sessionSecret,
100
+ siteOrigin,
101
+ redirectUri: `${siteOrigin}${HUB_SESSION_CALLBACK_PATH}`,
102
+ secure: siteOrigin.startsWith("https:"),
103
+ };
104
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The spoke's hub-client module (#7894 D7) — sign-in through the hub, with no
3
+ * database. It imports no spoke removal seed and no `(auth)` file, so a spoke
4
+ * keeps it (create-ailk's closure guard proves that, spec 7894 D9).
5
+ *
6
+ * The signed-out gate (`sign-in-gate.tsx`) is imported by its own path from
7
+ * the generated gate seam, so the server-side readers below never pull a
8
+ * React tree into the modules that call them.
9
+ */
10
+
11
+ export {
12
+ HUB_SESSION_CALLBACK_PATH,
13
+ type HubSessionConfig,
14
+ hubSessionConfig,
15
+ } from "./config";
16
+ export {
17
+ type HubSessionPayload,
18
+ type HubSessionUser,
19
+ hubNextPath,
20
+ hubSessionBuyer,
21
+ hubSessionInit,
22
+ readHubSession,
23
+ } from "./session";
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Sealed cookie values for the spoke's hub session (#7894 D7).
3
+ *
4
+ * AES-256-GCM with a fresh 12-byte IV per seal. The key is HKDF-SHA256 of
5
+ * `AILK_HUB_SESSION_SECRET` with one info string per cookie purpose, so the
6
+ * session cookie and the flow cookie never share a key, and the cookie's NAME
7
+ * is the additional authenticated data, so a value sealed for one cookie never
8
+ * opens as the other. The key never derives from the client secret: a leaked
9
+ * client secret forges no spoke session.
10
+ *
11
+ * Wire form: base64url(iv ‖ tag ‖ ciphertext). Anything that does not open —
12
+ * a wrong key, a wrong name, a truncated or altered value — reads as `null`,
13
+ * never a throw. Node `crypto` only; no dependency.
14
+ */
15
+
16
+ import {
17
+ createCipheriv,
18
+ createDecipheriv,
19
+ hkdfSync,
20
+ randomBytes,
21
+ } from "node:crypto";
22
+
23
+ /** The two things this module seals, each under its own derived key. */
24
+ export type SealPurpose = "session" | "flow";
25
+
26
+ const INFO: Record<SealPurpose, string> = {
27
+ session: "ailk-hub-session/v1",
28
+ flow: "ailk-hub-flow/v1",
29
+ };
30
+
31
+ const IV_BYTES = 12;
32
+ const TAG_BYTES = 16;
33
+
34
+ function deriveKey(secret: string, purpose: SealPurpose): Buffer {
35
+ return Buffer.from(
36
+ hkdfSync("sha256", secret, Buffer.alloc(0), INFO[purpose], 32),
37
+ );
38
+ }
39
+
40
+ /** `value` as JSON, sealed for the cookie `name`. */
41
+ export function seal(
42
+ value: unknown,
43
+ secret: string,
44
+ purpose: SealPurpose,
45
+ name: string,
46
+ ): string {
47
+ const iv = randomBytes(IV_BYTES);
48
+ const cipher = createCipheriv("aes-256-gcm", deriveKey(secret, purpose), iv);
49
+ cipher.setAAD(Buffer.from(name, "utf8"));
50
+ const body = Buffer.concat([
51
+ cipher.update(JSON.stringify(value), "utf8"),
52
+ cipher.final(),
53
+ ]);
54
+ return Buffer.concat([iv, cipher.getAuthTag(), body]).toString("base64url");
55
+ }
56
+
57
+ /** The JSON value `sealed` holds for the cookie `name`, or `null` when it does not open. */
58
+ export function unseal(
59
+ sealed: string,
60
+ secret: string,
61
+ purpose: SealPurpose,
62
+ name: string,
63
+ ): unknown {
64
+ try {
65
+ const raw = Buffer.from(sealed, "base64url");
66
+ if (raw.length <= IV_BYTES + TAG_BYTES) return null;
67
+ const decipher = createDecipheriv(
68
+ "aes-256-gcm",
69
+ deriveKey(secret, purpose),
70
+ raw.subarray(0, IV_BYTES),
71
+ );
72
+ decipher.setAAD(Buffer.from(name, "utf8"));
73
+ decipher.setAuthTag(raw.subarray(IV_BYTES, IV_BYTES + TAG_BYTES));
74
+ const text = Buffer.concat([
75
+ decipher.update(raw.subarray(IV_BYTES + TAG_BYTES)),
76
+ decipher.final(),
77
+ ]).toString("utf8");
78
+ return JSON.parse(text) as unknown;
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The spoke's hub session (#7894 D7, D12, D13) — read, forwarded, never stored.
3
+ *
4
+ * A spoke that signs in through its hub has no database: after the callback
5
+ * route exchanges a code, the hub-issued credential and the person it names
6
+ * live in ONE sealed, `HttpOnly` cookie. This module is everything that reads
7
+ * it:
8
+ *
9
+ * - `readHubSession(headers)` — the web seam's `readSession` on a hub-mode
10
+ * spoke: unseals the cookie from the `Cookie` header, no network. Expired,
11
+ * unreadable or sealed under another key ⇒ `null`.
12
+ * - `hubSessionInit()` — what `forwardedCookieInit()` returns on such a
13
+ * spoke: the credential as `Authorization: Bearer`, the ONE request-init
14
+ * re-target the account area reads the hub through (D12). The api-client
15
+ * adds the spoke key itself; the hub admits the credential only beside it.
16
+ * - `hubSessionBuyer(request)` — a customer the hub vouches for at buy time
17
+ * (D13): read live from `GET /v1/hub-sign-in/me`, never from the cookie.
18
+ *
19
+ * Nothing here logs a credential, a cookie value or an email.
20
+ */
21
+
22
+ import { hubSignIn } from "@working-theory/api-client";
23
+ import { headers as requestHeaders } from "next/headers";
24
+
25
+ import { resolveNextPath } from "../next-path";
26
+
27
+ import { type HubSessionConfig, hubSessionConfig } from "./config";
28
+ import { unseal } from "./seal";
29
+
30
+ /** The session cookie: `__Host-` on an https site, plain on a loopback one. */
31
+ export function sessionCookieName(config: Pick<HubSessionConfig, "secure">): string {
32
+ return config.secure ? "__Host-ailk-hub-session" : "ailk-hub-session";
33
+ }
34
+
35
+ /** The flow cookie that carries state, verifier and `next` across the hub hop. */
36
+ export function flowCookieName(config: Pick<HubSessionConfig, "secure">): string {
37
+ return config.secure ? "__Host-ailk-hub-flow" : "ailk-hub-flow";
38
+ }
39
+
40
+ /** The person a hub session names — the fields the account area reads. */
41
+ export interface HubSessionUser {
42
+ name: string | null;
43
+ email: string;
44
+ emailVerified: boolean;
45
+ createdAt: string;
46
+ }
47
+
48
+ /** What the session cookie holds (sealed). */
49
+ export interface HubSessionPayload {
50
+ v: 1;
51
+ accessToken: string;
52
+ /** Epoch milliseconds; the cookie's own `Max-Age` ends at the same time. */
53
+ expiresAt: number;
54
+ user: HubSessionUser;
55
+ }
56
+
57
+ /** The value of cookie `name` in a `Cookie` header, or `undefined`. */
58
+ export function readCookie(header: string | null, name: string): string | undefined {
59
+ if (header === null) return undefined;
60
+ for (const part of header.split(";")) {
61
+ const at = part.indexOf("=");
62
+ if (at === -1) continue;
63
+ if (part.slice(0, at).trim() === name) return part.slice(at + 1).trim();
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /**
69
+ * A `Set-Cookie` value for one of this module's cookies — always `HttpOnly`,
70
+ * `SameSite=Lax` (the hub's top-level redirect back must carry the flow
71
+ * cookie) and `Path=/`; `Secure` on an https site. `maxAge` 0 clears it.
72
+ */
73
+ export function cookieHeader(
74
+ name: string,
75
+ value: string,
76
+ maxAge: number,
77
+ config: Pick<HubSessionConfig, "secure">,
78
+ ): string {
79
+ return [
80
+ `${name}=${value}`,
81
+ "Path=/",
82
+ "HttpOnly",
83
+ "SameSite=Lax",
84
+ `Max-Age=${Math.max(0, Math.floor(maxAge))}`,
85
+ ...(config.secure ? ["Secure"] : []),
86
+ ].join("; ");
87
+ }
88
+
89
+ function isPayload(value: unknown): value is HubSessionPayload {
90
+ if (typeof value !== "object" || value === null) return false;
91
+ const v = value as Record<string, unknown>;
92
+ const user = v["user"] as Record<string, unknown> | null | undefined;
93
+ return (
94
+ v["v"] === 1 &&
95
+ typeof v["accessToken"] === "string" &&
96
+ typeof v["expiresAt"] === "number" &&
97
+ typeof user === "object" &&
98
+ user !== null &&
99
+ typeof user["email"] === "string" &&
100
+ typeof user["emailVerified"] === "boolean" &&
101
+ (user["name"] === null || typeof user["name"] === "string") &&
102
+ typeof user["createdAt"] === "string"
103
+ );
104
+ }
105
+
106
+ /** The sealed session in `headers`, or `null` — not configured, absent, expired or unreadable. */
107
+ export function readHubSessionPayload(headers: Headers): HubSessionPayload | null {
108
+ const config = hubSessionConfig();
109
+ if (config === null) return null;
110
+ const name = sessionCookieName(config);
111
+ const sealed = readCookie(headers.get("cookie"), name);
112
+ if (sealed === undefined || sealed === "") return null;
113
+ const value = unseal(sealed, config.sessionSecret, "session", name);
114
+ if (!isPayload(value) || value.expiresAt <= Date.now()) return null;
115
+ return value;
116
+ }
117
+
118
+ /** The web seam's `readSession` on a hub-mode spoke: the person, or `null`. */
119
+ export function readHubSession(
120
+ headers: Headers,
121
+ ): Promise<{ user: HubSessionUser } | null> {
122
+ const payload = readHubSessionPayload(headers);
123
+ return Promise.resolve(payload === null ? null : { user: payload.user });
124
+ }
125
+
126
+ /**
127
+ * `forwardedCookieInit()` on a hub-mode spoke (D12): the credential as a
128
+ * Bearer header signed in, no header at all signed out. Never the visitor's
129
+ * cookie — the hub has no session for it.
130
+ */
131
+ export async function hubSessionInit(): Promise<{ headers: Record<string, string> }> {
132
+ const payload = readHubSessionPayload(await requestHeaders());
133
+ return payload === null
134
+ ? { headers: {} }
135
+ : { headers: { authorization: `Bearer ${payload.accessToken}` } };
136
+ }
137
+
138
+ /**
139
+ * The customer the hub vouches for, for a spoke's buy route (D13): read live
140
+ * from `GET /v1/hub-sign-in/me` with the person's credential (the api-client
141
+ * adds the spoke key). `null` with no session, when the hub refuses the
142
+ * credential (a revoked one reads as signed out here), when it names no
143
+ * customer, or on any failure — a buyer is never taken from the cookie.
144
+ */
145
+ export async function hubSessionBuyer(
146
+ request: Request,
147
+ ): Promise<{ customerId: string } | null> {
148
+ const payload = readHubSessionPayload(request.headers);
149
+ if (payload === null) return null;
150
+ try {
151
+ const res = await hubSignIn.me({
152
+ headers: { authorization: `Bearer ${payload.accessToken}` },
153
+ });
154
+ if (!res.ok) return null;
155
+ const customerId = res.body.billingCustomerId;
156
+ return typeof customerId === "string" && customerId !== ""
157
+ ? { customerId }
158
+ : null;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /** Where to land after signing in, when `next` is missing or refused. */
165
+ export const HUB_SESSION_FALLBACK_PATH = "/account";
166
+
167
+ /**
168
+ * The post-sign-in path for `raw`: `resolveNextPath`'s same-origin rule,
169
+ * printable ASCII only (it becomes a `Location` header), and never a path
170
+ * that resolves under `/api` — dot segments included — so a sign-in lands on
171
+ * a page and never re-enters a route handler.
172
+ */
173
+ export function hubNextPath(raw: unknown): string {
174
+ const next = resolveNextPath(raw, HUB_SESSION_FALLBACK_PATH);
175
+ if (!/^[\x21-\x7e]+$/.test(next)) return HUB_SESSION_FALLBACK_PATH;
176
+ const path = new URL(next, "http://site.invalid").pathname;
177
+ return path === "/api" || path.startsWith("/api/")
178
+ ? HUB_SESSION_FALLBACK_PATH
179
+ : next;
180
+ }