create-ailk 0.14.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -0,0 +1,156 @@
1
+ /**
2
+ * The hub's browser step of "sign in through the hub" (#7894, spec
3
+ * docs/specs/7894-the-hub-signs-in-its-spec.md D2, D4).
4
+ *
5
+ * A spoke redirects a visitor here; this route is the one place the person
6
+ * must be signed in on the HUB'S OWN origin, because that is where the Better
7
+ * Auth session cookie lives. It owns no stored artifact: codes and credentials
8
+ * live behind `apps/api` (`POST /v1/hub-sign-in/codes`), reached through
9
+ * `@working-theory/api-client` with the visitor's own cookie.
10
+ *
11
+ * GET /api/auth/hub/authorize
12
+ * ?response_type=code&client_id=…&redirect_uri=…&state=…
13
+ * &code_challenge=…&code_challenge_method=S256
14
+ *
15
+ * - a malformed query is a 400 and NEVER a redirect;
16
+ * - no Better Auth session: 303 to `/sign-in?next=<this path and query>` —
17
+ * a same-origin path, so `resolveNextPath` keeps it and the existing
18
+ * sign-in card hands it to Better Auth as the callback for Google and for
19
+ * the emailed link alike (the hub's own `magicLink` sends the link, and it
20
+ * returns through this step);
21
+ * - a session: the API mints a single-use code, and this route redirects to
22
+ * the REGISTERED redirect URI the API echoes back, with `code` and the
23
+ * same `state` — never to the query's own `redirect_uri` until the API has
24
+ * confirmed it is the client's;
25
+ * - a person whose email is not verified (403 `email_unverified`) goes to the
26
+ * hub's own `/account/settings`, whose For-you item says how to verify —
27
+ * never to the spoke;
28
+ * - any other refusal is a 400 with `{ "error": "invalid_request" }` and no
29
+ * visitor copy: only a misconfigured client reaches it.
30
+ *
31
+ * `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
32
+ * (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
33
+ * origin, exactly as the emailed link does.
34
+ *
35
+ * This is the second api-shaped route under `apps/web/app/api/auth/`, beside
36
+ * Better Auth's catch-all; it sits under `app/api/auth`, a spoke removal seed
37
+ * (spec 7426 D4), so no spoke ever carries it. Nothing here logs a code, a
38
+ * state, a challenge or a cookie.
39
+ */
40
+ import { hubSignIn } from "@working-theory/api-client";
41
+ import { hubAuthorizeQuerySchema } from "@working-theory/validation";
42
+
43
+ import { auth } from "../../../../../auth";
44
+
45
+ /** The six query keys, each read exactly once. */
46
+ const QUERY_KEYS = [
47
+ "response_type",
48
+ "client_id",
49
+ "redirect_uri",
50
+ "state",
51
+ "code_challenge",
52
+ "code_challenge_method",
53
+ ] as const;
54
+
55
+ /** Where an unverified person is sent to verify: the hub's own account page. */
56
+ const VERIFY_EMAIL_PATH = "/account/settings";
57
+
58
+ function json(status: number, error: string): Response {
59
+ return new Response(JSON.stringify({ error }), {
60
+ status,
61
+ headers: {
62
+ "Content-Type": "application/json",
63
+ "Cache-Control": "no-store",
64
+ },
65
+ });
66
+ }
67
+
68
+ /** A 303 whose target may be a same-origin path or an absolute spoke URL. */
69
+ function see(location: string): Response {
70
+ return new Response(null, {
71
+ status: 303,
72
+ headers: {
73
+ Location: location,
74
+ "Cache-Control": "no-store",
75
+ // The code rides this URL; no page may leak it through a Referer.
76
+ "Referrer-Policy": "no-referrer",
77
+ },
78
+ });
79
+ }
80
+
81
+ /** The pinned origin of the hub's own web app, or `null` when unset/invalid. */
82
+ function pinnedOrigin(): string | null {
83
+ const raw = process.env.BETTER_AUTH_URL;
84
+ if (!raw) return null;
85
+ try {
86
+ return new URL(raw).origin;
87
+ } catch {
88
+ return null;
89
+ }
90
+ }
91
+
92
+ // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
93
+ export async function GET(request: Request): Promise<Response> {
94
+ const url = new URL(request.url);
95
+
96
+ // Each declared key exactly once: a repeated key is ambiguous, so 400.
97
+ const raw: Record<string, string | undefined> = {};
98
+ for (const key of QUERY_KEYS) {
99
+ const values = url.searchParams.getAll(key);
100
+ if (values.length > 1) return json(400, "invalid_request");
101
+ raw[key] = values[0];
102
+ }
103
+ const parsed = hubAuthorizeQuerySchema.safeParse(raw);
104
+ if (!parsed.success) return json(400, "invalid_request");
105
+ const query = parsed.data;
106
+
107
+ const session = await auth.api.getSession({ headers: request.headers });
108
+ if (!session) {
109
+ const next = `${url.pathname}${url.search}`;
110
+ return see(`/sign-in?next=${encodeURIComponent(next)}`);
111
+ }
112
+
113
+ const origin = pinnedOrigin();
114
+ if (origin === null) return json(503, "temporarily_unavailable");
115
+
116
+ let result: Awaited<ReturnType<typeof hubSignIn.createCode>>;
117
+ try {
118
+ result = await hubSignIn.createCode(
119
+ {
120
+ clientId: query.client_id,
121
+ redirectUri: query.redirect_uri,
122
+ codeChallenge: query.code_challenge,
123
+ },
124
+ {
125
+ headers: {
126
+ cookie: request.headers.get("cookie") ?? "",
127
+ origin,
128
+ },
129
+ },
130
+ );
131
+ } catch {
132
+ return json(503, "temporarily_unavailable");
133
+ }
134
+
135
+ if (result.ok) {
136
+ // Redirect to the URI the API confirmed is registered for this client.
137
+ let target: URL;
138
+ try {
139
+ target = new URL(result.body.redirectUri);
140
+ } catch {
141
+ // A registered URI is validated at registration; an unparseable echo is
142
+ // a server fault, never something to redirect to.
143
+ return json(503, "temporarily_unavailable");
144
+ }
145
+ target.searchParams.set("code", result.body.code);
146
+ target.searchParams.set("state", query.state);
147
+ return see(target.toString());
148
+ }
149
+
150
+ const code = (result.body as { error?: { code?: unknown } } | null)?.error
151
+ ?.code;
152
+ if (result.status === 403 && code === "email_unverified") {
153
+ return see(VERIFY_EMAIL_PATH);
154
+ }
155
+ return json(400, "invalid_request");
156
+ }
@@ -0,0 +1,341 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The spoke's three hub-session routes (#7894 D2, D7; spec
7
+ * docs/specs/7894-the-hub-signs-in-its-spec.md AC-3, AC-5).
8
+ *
9
+ * The api-client's `hubSignIn` (the hub API) is the one external boundary
10
+ * mocked; the seal, the cookies and the routes' decisions are real. Covers:
11
+ * the authorize redirect (PKCE S256, a state, the configured redirect URI —
12
+ * never one built from `Host`), the callback's state check and its exchange,
13
+ * a callback that BURNS a code it cannot complete (AC-5), and a sign-out that
14
+ * refuses a cross-site request and revokes nothing (AC-5). Each route is a 404
15
+ * unless the web seam's `SIGN_IN_PROVIDER` is `"hub"` (AC-9).
16
+ */
17
+
18
+ import { createHash } from "node:crypto";
19
+
20
+ const mockExchange = jest.fn();
21
+ const mockRevoke = jest.fn();
22
+ jest.mock("@working-theory/api-client", () => ({
23
+ __esModule: true,
24
+ hubSignIn: {
25
+ exchange: (...args: unknown[]) => mockExchange(...args),
26
+ revoke: (...args: unknown[]) => mockRevoke(...args),
27
+ },
28
+ }));
29
+
30
+ const mockProvider: { value: "site" | "hub" | "none" } = { value: "hub" };
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("../../../../lib/topology.generated", () => ({
33
+ __esModule: true,
34
+ get SIGN_IN_PROVIDER() {
35
+ return mockProvider.value;
36
+ },
37
+ }));
38
+
39
+ import { seal } from "../../../../lib/hub-session/seal";
40
+ import { GET as callback } from "../callback/route";
41
+ import { GET as signIn } from "../sign-in/route";
42
+ import { POST as signOut } from "../sign-out/route";
43
+
44
+ const ENV = process.env;
45
+ const SITE = "http://127.0.0.1:3100";
46
+ const HUB = "http://127.0.0.1:4100";
47
+ const SESSION_SECRET = "s".repeat(43);
48
+ const CLIENT = { clientId: "spoke_0001", clientSecret: `ailk_hcs_${"c".repeat(43)}` };
49
+ const CREDENTIAL = `ailk_hst_${"t".repeat(43)}`;
50
+ const CODE = "k".repeat(43);
51
+ const USER = {
52
+ id: "user_1",
53
+ name: "Ada Lovelace",
54
+ email: "ada@example.com",
55
+ emailVerified: true,
56
+ createdAt: "2026-09-01T00:00:00.000Z",
57
+ };
58
+
59
+ function configure(overrides: Record<string, string | undefined> = {}): void {
60
+ process.env = {
61
+ ...ENV,
62
+ AILK_HUB_SIGN_IN_URL: HUB,
63
+ AILK_HUB_CLIENT_ID: CLIENT.clientId,
64
+ AILK_HUB_CLIENT_SECRET: CLIENT.clientSecret,
65
+ AILK_HUB_SESSION_SECRET: SESSION_SECRET,
66
+ NEXT_PUBLIC_BASE_URL: SITE,
67
+ };
68
+ for (const [key, value] of Object.entries(overrides)) {
69
+ if (value === undefined) delete process.env[key];
70
+ else process.env[key] = value;
71
+ }
72
+ }
73
+
74
+ /** Each `Set-Cookie` the response carries. */
75
+ function setCookies(res: Response): string[] {
76
+ return res.headers.getSetCookie();
77
+ }
78
+
79
+ /** `name=value` of the cookie `name` in `res`'s `Set-Cookie` headers. */
80
+ function cookieValue(res: Response, name: string): string | undefined {
81
+ const line = setCookies(res).find((c) => c.startsWith(`${name}=`));
82
+ return line?.slice(name.length + 1).split(";")[0];
83
+ }
84
+
85
+ /** Start a sign-in and return the flow cookie and the authorize URL. */
86
+ function start(next = "/account"): { flow: string; authorize: URL; res: Response } {
87
+ const res = signIn(
88
+ new Request(`${SITE}/api/hub-session/sign-in?next=${encodeURIComponent(next)}`, {
89
+ // A Host unlike the site's: the redirect URI must not follow it.
90
+ headers: { host: "attacker.example.com" },
91
+ }),
92
+ );
93
+ const flow = cookieValue(res, "ailk-hub-flow");
94
+ if (flow === undefined) throw new Error("no flow cookie");
95
+ return { flow, authorize: new URL(res.headers.get("location") ?? ""), res };
96
+ }
97
+
98
+ function callbackRequest(query: string, cookie?: string): Request {
99
+ return new Request(`${SITE}/api/hub-session/callback?${query}`, {
100
+ headers: cookie === undefined ? {} : { cookie },
101
+ });
102
+ }
103
+
104
+ beforeEach(() => {
105
+ configure();
106
+ mockProvider.value = "hub";
107
+ mockExchange.mockReset();
108
+ mockRevoke.mockReset().mockResolvedValue({ ok: true, body: { revoked: true } });
109
+ jest.spyOn(console, "warn").mockImplementation(() => undefined);
110
+ jest.spyOn(console, "error").mockImplementation(() => undefined);
111
+ });
112
+
113
+ afterEach(() => {
114
+ jest.restoreAllMocks();
115
+ });
116
+
117
+ afterAll(() => {
118
+ process.env = ENV;
119
+ });
120
+
121
+ describe("GET /api/hub-session/sign-in", () => {
122
+ it("303s to the hub's authorize step with PKCE S256, a state and the configured callback, and sets the flow cookie", () => {
123
+ const { authorize, res } = start();
124
+ expect(res.status).toBe(303);
125
+ expect(`${authorize.origin}${authorize.pathname}`).toBe(`${HUB}/api/auth/hub/authorize`);
126
+ const q = authorize.searchParams;
127
+ expect(q.get("response_type")).toBe("code");
128
+ expect(q.get("client_id")).toBe(CLIENT.clientId);
129
+ expect(q.get("redirect_uri")).toBe(`${SITE}/api/hub-session/callback`);
130
+ expect(q.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
131
+ expect(q.get("code_challenge_method")).toBe("S256");
132
+ expect(q.get("state")).toMatch(/^[A-Za-z0-9_-]{43}$/);
133
+ const flow = setCookies(res).find((c) => c.startsWith("ailk-hub-flow="));
134
+ expect(flow).toMatch(/; HttpOnly/);
135
+ expect(flow).toMatch(/; SameSite=Lax/);
136
+ expect(flow).toMatch(/; Max-Age=600/);
137
+ expect(flow).not.toMatch(/Secure/);
138
+ });
139
+
140
+ it("names the __Host- cookie, Secure, on an https site", () => {
141
+ configure({ NEXT_PUBLIC_BASE_URL: "https://spoke.example.com" });
142
+ const res = signIn(new Request("https://spoke.example.com/api/hub-session/sign-in"));
143
+ const flow = setCookies(res).find((c) => c.startsWith("__Host-ailk-hub-flow="));
144
+ expect(flow).toMatch(/; Secure$/);
145
+ expect(new URL(res.headers.get("location") ?? "").searchParams.get("redirect_uri")).toBe(
146
+ "https://spoke.example.com/api/hub-session/callback",
147
+ );
148
+ });
149
+
150
+ it("with no configuration 303s to /account and sets no cookie", () => {
151
+ configure({ AILK_HUB_CLIENT_SECRET: undefined });
152
+ const res = signIn(new Request(`${SITE}/api/hub-session/sign-in?next=/account`));
153
+ expect(res.status).toBe(303);
154
+ expect(res.headers.get("location")).toBe("/account");
155
+ expect(setCookies(res)).toEqual([]);
156
+ });
157
+
158
+ it.each(["site", "none"] as const)("is 404 when the provider is %s", (provider) => {
159
+ mockProvider.value = provider;
160
+ expect(signIn(new Request(`${SITE}/api/hub-session/sign-in`)).status).toBe(404);
161
+ });
162
+ });
163
+
164
+ describe("GET /api/hub-session/callback", () => {
165
+ function exchangeOk(): void {
166
+ mockExchange.mockResolvedValue({
167
+ ok: true,
168
+ body: { accessToken: CREDENTIAL, tokenType: "Bearer", expiresIn: 86400, user: USER },
169
+ });
170
+ }
171
+
172
+ it("exchanges the code with the verifier whose S256 is the challenge, seals the session and lands on next", async () => {
173
+ exchangeOk();
174
+ const { flow, authorize } = start("/fr/account/inbox");
175
+ const state = authorize.searchParams.get("state") ?? "";
176
+ const res = await callback(
177
+ callbackRequest(`code=${CODE}&state=${state}`, `ailk-hub-flow=${flow}`),
178
+ );
179
+
180
+ expect(res.status).toBe(303);
181
+ expect(res.headers.get("location")).toBe("/fr/account/inbox");
182
+ expect(mockExchange).toHaveBeenCalledTimes(1);
183
+ const [auth, body] = mockExchange.mock.calls[0] as [
184
+ typeof CLIENT,
185
+ { code: string; redirectUri: string; codeVerifier: string },
186
+ ];
187
+ expect(auth).toEqual(CLIENT);
188
+ expect(body.code).toBe(CODE);
189
+ expect(body.redirectUri).toBe(`${SITE}/api/hub-session/callback`);
190
+ expect(createHash("sha256").update(body.codeVerifier).digest("base64url")).toBe(
191
+ authorize.searchParams.get("code_challenge"),
192
+ );
193
+ const session = setCookies(res).find((c) => c.startsWith("ailk-hub-session="));
194
+ expect(session).toMatch(/; HttpOnly/);
195
+ expect(session).toMatch(/; Max-Age=86400/);
196
+ // The flow cookie is cleared, and the credential never appears in clear.
197
+ expect(setCookies(res).find((c) => c.startsWith("ailk-hub-flow="))).toMatch(/Max-Age=0/);
198
+ expect(setCookies(res).join("\n")).not.toContain(CREDENTIAL);
199
+ expect(mockRevoke).not.toHaveBeenCalled();
200
+ });
201
+
202
+ it("lands on the flow cookie's next, never a next in the callback's query", async () => {
203
+ exchangeOk();
204
+ const { flow, authorize } = start("/account");
205
+ const state = authorize.searchParams.get("state") ?? "";
206
+ const res = await callback(
207
+ callbackRequest(`code=${CODE}&state=${state}&next=%2F%2Fevil.example.com`, `ailk-hub-flow=${flow}`),
208
+ );
209
+ expect(res.headers.get("location")).toBe("/account");
210
+ });
211
+
212
+ it("AC-5: without its flow cookie, burns the code at the hub and exchanges nothing", async () => {
213
+ const res = await callback(callbackRequest(`code=${CODE}&state=${"s".repeat(43)}`));
214
+ expect(res.status).toBe(303);
215
+ expect(res.headers.get("location")).toBe("/account");
216
+ expect(mockExchange).not.toHaveBeenCalled();
217
+ expect(mockRevoke).toHaveBeenCalledTimes(1);
218
+ expect(mockRevoke).toHaveBeenCalledWith(CLIENT, { code: CODE });
219
+ expect(setCookies(res).some((c) => c.startsWith("ailk-hub-session="))).toBe(false);
220
+ });
221
+
222
+ it("burns the code on a state mismatch, a hub error, an expired flow and a failed exchange", async () => {
223
+ const { flow, authorize } = start();
224
+ const state = authorize.searchParams.get("state") ?? "";
225
+ const cookie = `ailk-hub-flow=${flow}`;
226
+
227
+ await callback(callbackRequest(`code=${CODE}&state=${"x".repeat(43)}`, cookie));
228
+ await callback(callbackRequest(`code=${CODE}&state=${state}&error=access_denied`, cookie));
229
+ const expired = seal(
230
+ { v: 1, state, verifier: "v".repeat(43), next: "/account", expiresAt: Date.now() - 1 },
231
+ SESSION_SECRET,
232
+ "flow",
233
+ "ailk-hub-flow",
234
+ );
235
+ await callback(callbackRequest(`code=${CODE}&state=${state}`, `ailk-hub-flow=${expired}`));
236
+ expect(mockExchange).not.toHaveBeenCalled();
237
+
238
+ mockExchange.mockResolvedValue({ ok: false, status: 400, body: { error: "invalid_grant" } });
239
+ const failed = await callback(callbackRequest(`code=${CODE}&state=${state}`, cookie));
240
+ expect(failed.headers.get("location")).toBe("/account");
241
+ expect(mockRevoke).toHaveBeenCalledTimes(4);
242
+ for (const call of mockRevoke.mock.calls) expect(call).toEqual([CLIENT, { code: CODE }]);
243
+ });
244
+
245
+ it.each([[0], [86_401], [Number.NaN], [undefined]])(
246
+ "refuses a token answer whose expiresIn is %s, setting no session",
247
+ async (expiresIn) => {
248
+ mockExchange.mockResolvedValue({
249
+ ok: true,
250
+ body: { accessToken: CREDENTIAL, tokenType: "Bearer", expiresIn, user: USER },
251
+ });
252
+ const { flow, authorize } = start();
253
+ const state = authorize.searchParams.get("state") ?? "";
254
+ const res = await callback(callbackRequest(`code=${CODE}&state=${state}`, `ailk-hub-flow=${flow}`));
255
+ expect(res.headers.get("location")).toBe("/account");
256
+ expect(setCookies(res).some((c) => c.startsWith("ailk-hub-session="))).toBe(false);
257
+ },
258
+ );
259
+
260
+ it("never logs the query string, the code or the state", async () => {
261
+ const warn = jest.spyOn(console, "warn");
262
+ await callback(callbackRequest(`code=${CODE}&state=${"s".repeat(43)}`));
263
+ const logged = warn.mock.calls.flat().join(" ");
264
+ expect(logged).toContain("reason=no_flow");
265
+ expect(logged).not.toContain(CODE);
266
+ expect(logged).not.toContain("s".repeat(43));
267
+ });
268
+
269
+ it("does not try to burn a malformed code", async () => {
270
+ await callback(callbackRequest("code=not-a-code&state=x"));
271
+ expect(mockRevoke).not.toHaveBeenCalled();
272
+ });
273
+
274
+ it("is 404 unless the provider is hub", async () => {
275
+ mockProvider.value = "none";
276
+ const res = await callback(callbackRequest(`code=${CODE}&state=x`));
277
+ expect(res.status).toBe(404);
278
+ expect(mockRevoke).not.toHaveBeenCalled();
279
+ });
280
+ });
281
+
282
+ describe("POST /api/hub-session/sign-out", () => {
283
+ function sessionCookie(): string {
284
+ const sealed = seal(
285
+ { v: 1, accessToken: CREDENTIAL, expiresAt: Date.now() + 60_000, user: USER },
286
+ SESSION_SECRET,
287
+ "session",
288
+ "ailk-hub-session",
289
+ );
290
+ return `ailk-hub-session=${sealed}`;
291
+ }
292
+
293
+ function post(headers: Record<string, string>): Promise<Response> {
294
+ return signOut(
295
+ new Request(`${SITE}/api/hub-session/sign-out`, {
296
+ method: "POST",
297
+ headers: { cookie: sessionCookie(), ...headers },
298
+ }),
299
+ );
300
+ }
301
+
302
+ it("same-origin: revokes the credential at the hub, clears the cookie and answers 204", async () => {
303
+ const res = await post({ "sec-fetch-site": "same-origin", origin: SITE });
304
+ expect(res.status).toBe(204);
305
+ expect(mockRevoke).toHaveBeenCalledTimes(1);
306
+ expect(mockRevoke).toHaveBeenCalledWith(CLIENT, { accessToken: CREDENTIAL });
307
+ expect(setCookies(res)).toEqual([
308
+ "ailk-hub-session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
309
+ ]);
310
+ });
311
+
312
+ it("AC-5: refuses Sec-Fetch-Site cross-site with 403, revoking nothing", async () => {
313
+ const res = await post({ "sec-fetch-site": "cross-site" });
314
+ expect(res.status).toBe(403);
315
+ expect(mockRevoke).not.toHaveBeenCalled();
316
+ expect(setCookies(res)).toEqual([]);
317
+ });
318
+
319
+ it("AC-5: refuses a foreign Origin with 403, revoking nothing", async () => {
320
+ const res = await post({ origin: "https://evil.example.com" });
321
+ expect(res.status).toBe(403);
322
+ expect(mockRevoke).not.toHaveBeenCalled();
323
+ });
324
+
325
+ it("refuses a request carrying neither header", async () => {
326
+ expect((await post({})).status).toBe(403);
327
+ expect(mockRevoke).not.toHaveBeenCalled();
328
+ });
329
+
330
+ it("still signs out when the hub cannot be reached", async () => {
331
+ mockRevoke.mockRejectedValue(new Error("network"));
332
+ const res = await post({ "sec-fetch-site": "same-origin" });
333
+ expect(res.status).toBe(204);
334
+ expect(setCookies(res)[0]).toMatch(/Max-Age=0/);
335
+ });
336
+
337
+ it("is 404 unless the provider is hub", async () => {
338
+ mockProvider.value = "site";
339
+ expect((await post({ "sec-fetch-site": "same-origin" })).status).toBe(404);
340
+ });
341
+ });
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Finish a sign-in through the hub (#7894 D2, D7) — `GET /api/hub-session/callback?code=…&state=…`.
3
+ *
4
+ * The URI an operator registers on the hub (`spokes sign-in-client
5
+ * --redirect-uri <site>/api/hub-session/callback`). It reads and clears the
6
+ * flow cookie, checks the returned `state` against it in constant time, and
7
+ * exchanges the code, the PKCE verifier and the client secret at the hub API
8
+ * (the api-client adds this site's spoke key; the hub requires both). On
9
+ * success the credential and its person are sealed into the session cookie,
10
+ * whose `Max-Age` is the credential's own lifetime, and the visitor lands on
11
+ * the flow cookie's `next` — never on a value from this request's query.
12
+ *
13
+ * Every other outcome — no flow cookie, an expired one, a state mismatch, an
14
+ * `error` from the hub, a failed exchange — lands on `/account` (its gate
15
+ * offers to try again) after BURNING any code it received
16
+ * (`hubSignIn.revoke({ code })`, best-effort): a code this route cannot
17
+ * complete must not stay live for someone else to complete (SEC-7894-4).
18
+ * The log line names the reason only — never the query string, a code, a
19
+ * state or a credential.
20
+ */
21
+ import { timingSafeEqual } from "node:crypto";
22
+
23
+ import { hubSignIn } from "@working-theory/api-client";
24
+ import { hubAuthorizationCodeSchema } from "@working-theory/validation";
25
+
26
+ import {
27
+ type HubSessionConfig,
28
+ hubSessionConfig,
29
+ } from "../../../../lib/hub-session/config";
30
+ import { seal, unseal } from "../../../../lib/hub-session/seal";
31
+ import {
32
+ HUB_SESSION_FALLBACK_PATH,
33
+ type HubSessionPayload,
34
+ cookieHeader,
35
+ flowCookieName,
36
+ hubNextPath,
37
+ readCookie,
38
+ sessionCookieName,
39
+ } from "../../../../lib/hub-session/session";
40
+ import { SIGN_IN_PROVIDER } from "../../../../lib/topology.generated";
41
+
42
+ /** The longest session the spoke keeps — the hub's own credential lifetime (spec 7894 D5). */
43
+ const MAX_SESSION_SECONDS = 86_400;
44
+
45
+ /** What the sign-in route sealed into the flow cookie. */
46
+ interface FlowPayload {
47
+ v: 1;
48
+ state: string;
49
+ verifier: string;
50
+ next: string;
51
+ expiresAt: number;
52
+ }
53
+
54
+ function isFlow(value: unknown): value is FlowPayload {
55
+ if (typeof value !== "object" || value === null) return false;
56
+ const v = value as Record<string, unknown>;
57
+ return (
58
+ v["v"] === 1 &&
59
+ typeof v["state"] === "string" &&
60
+ typeof v["verifier"] === "string" &&
61
+ typeof v["next"] === "string" &&
62
+ typeof v["expiresAt"] === "number"
63
+ );
64
+ }
65
+
66
+ function see(location: string, setCookies: readonly string[]): Response {
67
+ const headers = new Headers({
68
+ Location: location,
69
+ "Cache-Control": "no-store",
70
+ "Referrer-Policy": "no-referrer",
71
+ });
72
+ for (const value of setCookies) headers.append("Set-Cookie", value);
73
+ return new Response(null, { status: 303, headers });
74
+ }
75
+
76
+ function sameState(a: string, b: string): boolean {
77
+ const x = Buffer.from(a, "utf8");
78
+ const y = Buffer.from(b, "utf8");
79
+ return x.length === y.length && timingSafeEqual(x, y);
80
+ }
81
+
82
+ /** Burn a code this route cannot complete. Best-effort: a failure is logged, never thrown. */
83
+ async function burn(config: HubSessionConfig, code: string | null): Promise<void> {
84
+ if (code === null || !hubAuthorizationCodeSchema.safeParse(code).success) {
85
+ return;
86
+ }
87
+ try {
88
+ await hubSignIn.revoke(
89
+ { clientId: config.clientId, clientSecret: config.clientSecret },
90
+ { code },
91
+ );
92
+ } catch (err) {
93
+ console.error(
94
+ `[hub-session] code burn failed errName=${err instanceof Error ? err.name : "unknown"}`,
95
+ );
96
+ }
97
+ }
98
+
99
+ // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
100
+ export async function GET(request: Request): Promise<Response> {
101
+ if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
102
+ const config = hubSessionConfig();
103
+ if (config === null) return see(HUB_SESSION_FALLBACK_PATH, []);
104
+
105
+ const params = new URL(request.url).searchParams;
106
+ const code = params.get("code");
107
+ const flowName = flowCookieName(config);
108
+ const clearFlow = cookieHeader(flowName, "", 0, config);
109
+
110
+ const refuse = async (reason: string): Promise<Response> => {
111
+ await burn(config, code);
112
+ console.warn(`[hub-session] sign-in not completed reason=${reason}`);
113
+ return see(HUB_SESSION_FALLBACK_PATH, [clearFlow]);
114
+ };
115
+
116
+ const sealed = readCookie(request.headers.get("cookie"), flowName);
117
+ const flow =
118
+ sealed === undefined
119
+ ? null
120
+ : unseal(sealed, config.sessionSecret, "flow", flowName);
121
+ if (!isFlow(flow)) return refuse("no_flow");
122
+ if (flow.expiresAt <= Date.now()) return refuse("flow_expired");
123
+ const state = params.get("state");
124
+ if (state === null || !sameState(state, flow.state)) {
125
+ return refuse("state_mismatch");
126
+ }
127
+ if (params.has("error")) return refuse("hub_error");
128
+ if (code === null) return refuse("no_code");
129
+
130
+ let exchanged: Awaited<ReturnType<typeof hubSignIn.exchange>>;
131
+ try {
132
+ exchanged = await hubSignIn.exchange(
133
+ { clientId: config.clientId, clientSecret: config.clientSecret },
134
+ { code, redirectUri: config.redirectUri, codeVerifier: flow.verifier },
135
+ );
136
+ } catch {
137
+ return refuse("exchange_failed");
138
+ }
139
+ if (!exchanged.ok) return refuse(`exchange_status_${exchanged.status}`);
140
+
141
+ const { accessToken, expiresIn, user } = exchanged.body;
142
+ // The cookie's lifetime is the credential's: a malformed answer from the
143
+ // hub must never yield a session that does not expire.
144
+ if (!Number.isInteger(expiresIn) || expiresIn < 1 || expiresIn > MAX_SESSION_SECONDS) {
145
+ return refuse("token_response_invalid");
146
+ }
147
+ const payload: HubSessionPayload = {
148
+ v: 1,
149
+ accessToken,
150
+ expiresAt: Date.now() + expiresIn * 1000,
151
+ user: {
152
+ name: user.name,
153
+ email: user.email,
154
+ emailVerified: user.emailVerified,
155
+ createdAt: user.createdAt,
156
+ },
157
+ };
158
+ const sessionName = sessionCookieName(config);
159
+ return see(hubNextPath(flow.next), [
160
+ clearFlow,
161
+ cookieHeader(
162
+ sessionName,
163
+ seal(payload, config.sessionSecret, "session", sessionName),
164
+ expiresIn,
165
+ config,
166
+ ),
167
+ ]);
168
+ }