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,483 @@
1
+ /**
2
+ * @file hub-sign-in.test.ts
3
+ *
4
+ * The four hub-sign-in routes through a real Fastify instance, wired as
5
+ * `server.ts` wires them (#7894, spec
6
+ * docs/specs/7894-the-hub-signs-in-its-spec.md D5, D6; AC-4, AC-5):
7
+ * `codes` and `me` behind the auth preHandler, `token` and `revoke` in their
8
+ * own scope without one.
9
+ *
10
+ * The REAL preHandler, routes, service and store run over an in-memory stand-in
11
+ * for the Prisma delegates (`__tests__/support/hub-sign-in-fake-db.ts`).
12
+ *
13
+ * External dependency boundary mocked:
14
+ * - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
15
+ */
16
+
17
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
18
+ jest.mock("../../../lib/prisma.js");
19
+
20
+ import Fastify from "fastify";
21
+ import type { FastifyInstance } from "fastify";
22
+
23
+ import {
24
+ installHubSignInFakeDb,
25
+ pkcePair,
26
+ seedSpoke,
27
+ seedUser,
28
+ type FakeHubDb,
29
+ type FakeUser,
30
+ type SeededSpoke,
31
+ } from "../../../__tests__/support/hub-sign-in-fake-db.js";
32
+ import { hashAgentKey, mintAgentKey } from "../../../lib/agent-store.js";
33
+ import { prisma } from "../../../lib/prisma.js";
34
+ import { authPreHandlerPlugin } from "../../../middleware/auth.js";
35
+ import { __resetHubSignInBuckets } from "../../../services/hub-sign-in.js";
36
+ import {
37
+ hubSignInClientRoutes,
38
+ hubSignInSessionRoutes,
39
+ } from "../hub-sign-in.js";
40
+
41
+ const db = jest.mocked(prisma);
42
+
43
+ const HUB = "https://hub.example.com";
44
+ const FUTURE = new Date(Date.now() + 60 * 60 * 1000);
45
+ const COOKIE_TOKEN = "cookie_pat";
46
+
47
+ let state: FakeHubDb;
48
+ let app: FastifyInstance;
49
+ let user: FakeUser;
50
+ let spoke: SeededSpoke;
51
+
52
+ function buildApp(): FastifyInstance {
53
+ const server = Fastify();
54
+ server.register(async (authScope) => {
55
+ await authScope.register(authPreHandlerPlugin, { trustedOrigins: [HUB] });
56
+ await authScope.register(hubSignInSessionRoutes);
57
+ });
58
+ server.register(hubSignInClientRoutes);
59
+ return server;
60
+ }
61
+
62
+ beforeEach(async () => {
63
+ jest.resetAllMocks();
64
+ state = installHubSignInFakeDb(db);
65
+ __resetHubSignInBuckets();
66
+
67
+ user = seedUser(state);
68
+ spoke = seedSpoke(state);
69
+
70
+ // The Better Auth session the cookie path resolves.
71
+ (db.session.findUnique as jest.Mock).mockImplementation(
72
+ async ({ where }: { where: { token: string } }) =>
73
+ where.token === COOKIE_TOKEN
74
+ ? {
75
+ id: "sess_1",
76
+ userId: user.id,
77
+ expiresAt: FUTURE,
78
+ activeOrganizationId: null,
79
+ activeWorkspaceId: null,
80
+ }
81
+ : null,
82
+ );
83
+
84
+ app = buildApp();
85
+ await app.ready();
86
+ });
87
+
88
+ afterEach(async () => {
89
+ await app.close();
90
+ });
91
+
92
+ const cookieHeaders = () => ({
93
+ cookie: `better-auth.session_token=${COOKIE_TOKEN}`,
94
+ origin: HUB,
95
+ });
96
+
97
+ async function postCode(
98
+ headers: Record<string, string>,
99
+ overrides: Record<string, unknown> = {},
100
+ challenge = pkcePair().challenge,
101
+ ) {
102
+ return app.inject({
103
+ method: "POST",
104
+ url: "/v1/hub-sign-in/codes",
105
+ headers,
106
+ payload: {
107
+ clientId: spoke.client.clientId,
108
+ redirectUri: spoke.redirectUri,
109
+ codeChallenge: challenge,
110
+ ...overrides,
111
+ },
112
+ });
113
+ }
114
+
115
+ async function postToken(
116
+ s: SeededSpoke,
117
+ body: Record<string, unknown>,
118
+ headers: Record<string, string> = {},
119
+ ) {
120
+ return app.inject({
121
+ method: "POST",
122
+ url: "/v1/hub-sign-in/token",
123
+ headers: { authorization: s.basic, "x-ailk-spoke-key": s.key, ...headers },
124
+ payload: { redirectUri: s.redirectUri, ...body },
125
+ });
126
+ }
127
+
128
+ /** The whole sign-in, over HTTP: cookie → code → token. */
129
+ async function signIn(s: SeededSpoke = spoke): Promise<string> {
130
+ const { verifier, challenge } = pkcePair();
131
+ const minted = await app.inject({
132
+ method: "POST",
133
+ url: "/v1/hub-sign-in/codes",
134
+ headers: cookieHeaders(),
135
+ payload: {
136
+ clientId: s.client.clientId,
137
+ redirectUri: s.redirectUri,
138
+ codeChallenge: challenge,
139
+ },
140
+ });
141
+ expect(minted.statusCode).toBe(201);
142
+ const { code } = minted.json().data as { code: string };
143
+ const exchanged = await postToken(s, { code, codeVerifier: verifier });
144
+ expect(exchanged.statusCode).toBe(200);
145
+ return (exchanged.json().data as { accessToken: string }).accessToken;
146
+ }
147
+
148
+ describe("POST /v1/hub-sign-in/codes", () => {
149
+ it("a cookie session mints a code: 201, the envelope, no-store, no cookie", async () => {
150
+ const res = await postCode(cookieHeaders());
151
+
152
+ expect(res.statusCode).toBe(201);
153
+ expect(res.headers["cache-control"]).toBe("no-store");
154
+ expect(res.headers["set-cookie"]).toBeUndefined();
155
+ expect(res.json()).toEqual({
156
+ ok: true,
157
+ data: {
158
+ code: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/),
159
+ redirectUri: spoke.redirectUri,
160
+ },
161
+ });
162
+ expect(state.verifications).toHaveLength(1);
163
+ });
164
+
165
+ it("is 401 with no credential at all, and 401 for a cookie write with no Origin", async () => {
166
+ expect((await postCode({})).statusCode).toBe(401);
167
+ expect(
168
+ (await postCode({ cookie: cookieHeaders().cookie })).statusCode,
169
+ ).toBe(401);
170
+ expect(state.verifications).toHaveLength(0);
171
+ });
172
+
173
+ it("AC-5: refuses a personal ApiToken (403), an agent key (403) and a hub credential (404) — and writes no code", async () => {
174
+ // A personal API token: authenticated, but no cookie session.
175
+ (db.apiToken.findUnique as jest.Mock).mockResolvedValue({
176
+ id: "tok_1",
177
+ userId: user.id,
178
+ expiresAt: null,
179
+ revokedAt: null,
180
+ });
181
+ (db.apiToken.update as jest.Mock).mockResolvedValue({});
182
+ const apiToken = await postCode({ authorization: "Bearer tok_live_abc" });
183
+ expect(apiToken.statusCode).toBe(403);
184
+ expect(apiToken.json()).toEqual({
185
+ ok: false,
186
+ error: { code: "forbidden", message: "Forbidden" },
187
+ });
188
+
189
+ // An agent key: refused at the preHandler on any route outside a tenant guard.
190
+ const agentKey = mintAgentKey();
191
+ (db.agent.findUnique as jest.Mock).mockImplementation(
192
+ async ({ where }: { where: { credentialHash: string } }) =>
193
+ where.credentialHash === hashAgentKey(agentKey)
194
+ ? {
195
+ id: "agent_00000000-0000-7000-8000-0000000000c1",
196
+ workspaceId: "wksp_00000000-0000-7000-8000-0000000000a1",
197
+ organizationId: "org_00000000-0000-7000-8000-000000000aaa",
198
+ permissionLevel: "member",
199
+ enabled: true,
200
+ keyIssuedToMembershipId:
201
+ "member_00000000-0000-7000-8000-0000000000f1",
202
+ lastActiveAt: null,
203
+ responsible: {
204
+ id: "member_00000000-0000-7000-8000-0000000000f2",
205
+ userId: user.id,
206
+ },
207
+ }
208
+ : null,
209
+ );
210
+ (db.agent.update as jest.Mock).mockResolvedValue({});
211
+ const agent = await postCode({ authorization: `Bearer ${agentKey}` });
212
+ expect(agent.statusCode).toBe(403);
213
+
214
+ // A hub credential: this route is not on its list.
215
+ const credential = await signIn();
216
+ state.verifications.length = 0;
217
+ const hub = await postCode({
218
+ authorization: `Bearer ${credential}`,
219
+ "x-ailk-spoke-key": spoke.key,
220
+ });
221
+ expect(hub.statusCode).toBe(404);
222
+
223
+ expect(state.verifications).toHaveLength(0);
224
+ });
225
+
226
+ it("maps the service's refusals: unregistered callback 400, unverified email 403, bad body 400", async () => {
227
+ const callback = await postCode(cookieHeaders(), {
228
+ redirectUri: "https://attacker.example.com/api/hub-session/callback",
229
+ });
230
+ expect(callback.statusCode).toBe(400);
231
+ expect(callback.json().error.code).toBe("invalid_redirect_uri");
232
+
233
+ const body = await postCode(cookieHeaders(), { codeChallenge: "short" });
234
+ expect(body.statusCode).toBe(400);
235
+ expect(body.json().error.code).toBe("invalid_request");
236
+
237
+ user.emailVerified = false;
238
+ const unverified = await postCode(cookieHeaders());
239
+ expect(unverified.statusCode).toBe(403);
240
+ expect(unverified.json().error.code).toBe("email_unverified");
241
+ expect(state.verifications).toHaveLength(0);
242
+ });
243
+
244
+ it("answers 429 with Retry-After past the per-user budget", async () => {
245
+ let last = await postCode(cookieHeaders());
246
+ for (let i = 0; i < 30; i += 1) last = await postCode(cookieHeaders());
247
+ expect(last.statusCode).toBe(429);
248
+ expect(Number(last.headers["retry-after"])).toBeGreaterThan(0);
249
+ expect(last.json().error.code).toBe("rate_limited");
250
+ });
251
+ });
252
+
253
+ describe("AC-4: one cookie session, two spokes", () => {
254
+ it("signs the same person in to both: two grants, one userId, different clients — and each credential works only with its own spoke's key", async () => {
255
+ const other = seedSpoke(state, "other.example.com");
256
+
257
+ const credentialA = await signIn(spoke);
258
+ const credentialB = await signIn(other);
259
+
260
+ expect(state.authorizations).toHaveLength(2);
261
+ expect(new Set(state.authorizations.map((g) => g.userId))).toEqual(
262
+ new Set([user.id]),
263
+ );
264
+ expect(new Set(state.authorizations.map((g) => g.clientId)).size).toBe(2);
265
+
266
+ const me = (credential: string, s: SeededSpoke) =>
267
+ app.inject({
268
+ method: "GET",
269
+ url: "/v1/hub-sign-in/me",
270
+ headers: { authorization: `Bearer ${credential}`, "x-ailk-spoke-key": s.key },
271
+ });
272
+ expect((await me(credentialA, spoke)).statusCode).toBe(200);
273
+ expect((await me(credentialB, other)).statusCode).toBe(200);
274
+ // A's credential with B's key (and the reverse) is refused at the preHandler.
275
+ expect((await me(credentialA, other)).statusCode).toBe(401);
276
+ expect((await me(credentialB, spoke)).statusCode).toBe(401);
277
+ });
278
+ });
279
+
280
+ describe("POST /v1/hub-sign-in/token", () => {
281
+ it("exchanges a code: 200, the envelope, no-store, and NEVER a Set-Cookie or a Session", async () => {
282
+ const { verifier, challenge } = pkcePair();
283
+ const minted = await postCode(cookieHeaders(), {}, challenge);
284
+ const { code } = minted.json().data as { code: string };
285
+
286
+ const res = await postToken(spoke, { code, codeVerifier: verifier });
287
+
288
+ expect(res.statusCode).toBe(200);
289
+ expect(res.headers["set-cookie"]).toBeUndefined();
290
+ expect(res.headers["cache-control"]).toBe("no-store");
291
+ expect(res.json().data).toMatchObject({
292
+ accessToken: expect.stringMatching(/^ailk_hst_/),
293
+ tokenType: "Bearer",
294
+ expiresIn: 86400,
295
+ user: { id: user.id, email: user.email, emailVerified: true },
296
+ });
297
+ expect(db.session.create).not.toHaveBeenCalled();
298
+ expect(state.sessionCalls).not.toContain("create");
299
+ });
300
+
301
+ it("every refusal is the envelope with the right status and no Set-Cookie", async () => {
302
+ const { verifier, challenge } = pkcePair();
303
+ const minted = await postCode(cookieHeaders(), {}, challenge);
304
+ const { code } = minted.json().data as { code: string };
305
+
306
+ const noKey = await app.inject({
307
+ method: "POST",
308
+ url: "/v1/hub-sign-in/token",
309
+ headers: { authorization: spoke.basic },
310
+ payload: { code, redirectUri: spoke.redirectUri, codeVerifier: verifier },
311
+ });
312
+ expect(noKey.statusCode).toBe(401);
313
+ expect(noKey.json()).toEqual({
314
+ ok: false,
315
+ error: {
316
+ code: "invalid_client",
317
+ message: "Client authentication failed",
318
+ },
319
+ });
320
+
321
+ const wrongVerifier = await postToken(spoke, {
322
+ code,
323
+ codeVerifier: pkcePair().verifier,
324
+ });
325
+ expect(wrongVerifier.statusCode).toBe(400);
326
+ expect(wrongVerifier.json().error.code).toBe("invalid_grant");
327
+
328
+ const malformed = await postToken(spoke, {
329
+ code: "short",
330
+ codeVerifier: "v",
331
+ });
332
+ expect(malformed.statusCode).toBe(400);
333
+ expect(malformed.json().error.code).toBe("invalid_request");
334
+
335
+ for (const res of [noKey, wrongVerifier, malformed]) {
336
+ expect(res.headers["set-cookie"]).toBeUndefined();
337
+ }
338
+ expect(state.authorizations).toHaveLength(0);
339
+ });
340
+
341
+ it("is reachable without any person's session (it sits outside the auth scope)", async () => {
342
+ const res = await postToken(spoke, {
343
+ code: "x".repeat(43),
344
+ codeVerifier: pkcePair().verifier,
345
+ });
346
+ // 400 invalid_grant — not the preHandler's 401.
347
+ expect(res.statusCode).toBe(400);
348
+ expect(res.json().error.code).toBe("invalid_grant");
349
+ });
350
+ });
351
+
352
+ describe("POST /v1/hub-sign-in/revoke", () => {
353
+ it("revokes a credential, which the preHandler then refuses", async () => {
354
+ const credential = await signIn();
355
+ const me = () =>
356
+ app.inject({
357
+ method: "GET",
358
+ url: "/v1/hub-sign-in/me",
359
+ headers: {
360
+ authorization: `Bearer ${credential}`,
361
+ "x-ailk-spoke-key": spoke.key,
362
+ },
363
+ });
364
+ expect((await me()).statusCode).toBe(200);
365
+
366
+ const res = await app.inject({
367
+ method: "POST",
368
+ url: "/v1/hub-sign-in/revoke",
369
+ headers: { authorization: spoke.basic, "x-ailk-spoke-key": spoke.key },
370
+ payload: { accessToken: credential },
371
+ });
372
+ expect(res.statusCode).toBe(200);
373
+ expect(res.json()).toEqual({ ok: true, data: { revoked: true } });
374
+ expect((await me()).statusCode).toBe(401);
375
+ });
376
+
377
+ it("is 401 invalid_client without the spoke key", async () => {
378
+ const res = await app.inject({
379
+ method: "POST",
380
+ url: "/v1/hub-sign-in/revoke",
381
+ headers: { authorization: spoke.basic },
382
+ payload: { code: "x".repeat(43) },
383
+ });
384
+ expect(res.statusCode).toBe(401);
385
+ });
386
+ });
387
+
388
+ describe("GET /v1/hub-sign-in/me", () => {
389
+ it("answers the credential's person, with a null customer id when no Person of the spoke's workspace is theirs", async () => {
390
+ const credential = await signIn();
391
+
392
+ const res = await app.inject({
393
+ method: "GET",
394
+ url: "/v1/hub-sign-in/me",
395
+ headers: {
396
+ authorization: `Bearer ${credential}`,
397
+ "x-ailk-spoke-key": spoke.key,
398
+ },
399
+ });
400
+
401
+ expect(res.statusCode).toBe(200);
402
+ expect(res.headers["cache-control"]).toBe("no-store");
403
+ expect(res.json()).toEqual({
404
+ ok: true,
405
+ data: {
406
+ user: {
407
+ id: user.id,
408
+ name: user.name,
409
+ email: user.email,
410
+ emailVerified: true,
411
+ createdAt: user.createdAt.toISOString(),
412
+ },
413
+ billingCustomerId: null,
414
+ },
415
+ });
416
+ });
417
+
418
+ it("vouches for the customer id of the Person in the credential's OWN spoke workspace (#7961, D13)", async () => {
419
+ const other = seedSpoke(state, "other.example.com");
420
+ const people = [
421
+ { id: "person_a", workspaceId: spoke.spoke.workspaceId, email: user.email, userId: user.id, billingCustomerId: "cus_a" },
422
+ { id: "person_b", workspaceId: other.spoke.workspaceId, email: user.email, userId: user.id, billingCustomerId: "cus_b" },
423
+ ];
424
+ db.person.findFirst.mockImplementation((async ({
425
+ where,
426
+ }: {
427
+ where: Record<string, unknown>;
428
+ }) =>
429
+ people.find((row) =>
430
+ Object.entries(where).every(
431
+ ([key, value]) => row[key as keyof (typeof people)[number]] === value,
432
+ ),
433
+ ) ?? null) as never);
434
+
435
+ for (const [s, expected] of [
436
+ [spoke, "cus_a"],
437
+ [other, "cus_b"],
438
+ ] as const) {
439
+ const credential = await signIn(s);
440
+ const res = await app.inject({
441
+ method: "GET",
442
+ url: "/v1/hub-sign-in/me",
443
+ headers: {
444
+ authorization: `Bearer ${credential}`,
445
+ "x-ailk-spoke-key": s.key,
446
+ },
447
+ });
448
+ expect(res.statusCode).toBe(200);
449
+ expect(res.json().data.billingCustomerId).toBe(expected);
450
+ }
451
+ });
452
+
453
+ it("is 404 for a cookie session — only the hub credential reaches it", async () => {
454
+ const res = await app.inject({
455
+ method: "GET",
456
+ url: "/v1/hub-sign-in/me",
457
+ headers: cookieHeaders(),
458
+ });
459
+ expect(res.statusCode).toBe(404);
460
+ });
461
+
462
+ it("is 401 with the credential but no spoke key, or with another spoke's key", async () => {
463
+ const credential = await signIn();
464
+ const other = seedSpoke(state, "other.example.com");
465
+
466
+ const noKey = await app.inject({
467
+ method: "GET",
468
+ url: "/v1/hub-sign-in/me",
469
+ headers: { authorization: `Bearer ${credential}` },
470
+ });
471
+ expect(noKey.statusCode).toBe(401);
472
+
473
+ const wrongKey = await app.inject({
474
+ method: "GET",
475
+ url: "/v1/hub-sign-in/me",
476
+ headers: {
477
+ authorization: `Bearer ${credential}`,
478
+ "x-ailk-spoke-key": other.key,
479
+ },
480
+ });
481
+ expect(wrongKey.statusCode).toBe(401);
482
+ });
483
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The hub signs in its spokes — four routes (#7894, spec
3
+ * docs/specs/7894-the-hub-signs-in-its-spec.md D5, D6).
4
+ *
5
+ * POST /v1/hub-sign-in/codes — a cookie session mints a single-use code
6
+ * POST /v1/hub-sign-in/token — the client trades a code for a credential
7
+ * POST /v1/hub-sign-in/revoke — the client ends a credential / burns a code
8
+ * GET /v1/hub-sign-in/me — the credential's person, read live, and the
9
+ * customer id the hub vouches for (D13)
10
+ *
11
+ * Two registrations, because two different callers reach them:
12
+ *
13
+ * - `hubSignInSessionRoutes` (`codes`, `me`) go in `server.ts`'s `authScope`:
14
+ * `request.userId` is set by the auth preHandler before either handler
15
+ * runs. `codes` answers only a cookie session (`request.sessionTenant` is
16
+ * the cookie-only signal): an `ApiToken` is 403 here, an agent key is 403
17
+ * at the preHandler, and a hub credential is 404 there — none of them
18
+ * reaches a code write. `me` answers only the hub credential
19
+ * (`request.hubSession` is set by that one branch); any other caller is 404.
20
+ * - `hubSignInClientRoutes` (`token`, `revoke`) go in their OWN scope outside
21
+ * `authScope`: the caller is a spoke's server, authenticated by its client
22
+ * secret and its spoke key, not by a person's session. The scope carries
23
+ * the per-IP limiter; the per-client bucket lives in the service, keyed on
24
+ * the VERIFIED client.
25
+ *
26
+ * Envelope per apps/api/CLAUDE.md: `{ ok: true, data }` /
27
+ * `{ ok: false, error: { code, message } }`, real HTTP status codes. Every
28
+ * success body is re-validated against its own `.strict()` contract before it
29
+ * leaves the process. Credentials and codes are never logged and every body
30
+ * that carries one is `Cache-Control: no-store`. No route here writes a
31
+ * `Session` row or sets a cookie.
32
+ *
33
+ * API↔MCP parity: a credential exchange between a site and its hub is not an
34
+ * agent-facing call — no MCP twin for any of the four (the same footing as
35
+ * `/v1/auth/tokens`; recorded in apps/api/CLAUDE.md).
36
+ */
37
+ import { logger } from "@working-theory/observability";
38
+ import {
39
+ hubSignInCodeResponseSchema,
40
+ hubSignInMeResponseSchema,
41
+ hubSignInRevokeResponseSchema,
42
+ hubSignInTokenResponseSchema,
43
+ } from "@working-theory/validation";
44
+ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
45
+ import type { ZodType } from "zod";
46
+
47
+ import {
48
+ createCode,
49
+ exchange,
50
+ readMe,
51
+ revoke,
52
+ type Result,
53
+ } from "../../services/hub-sign-in.js";
54
+
55
+ /** These bodies are a few hundred bytes; bound them well below the default. */
56
+ const BODY_LIMIT_BYTES = 4_096;
57
+
58
+ const SPOKE_KEY_HEADER = "x-ailk-spoke-key";
59
+
60
+ function error(
61
+ reply: FastifyReply,
62
+ status: number,
63
+ code: string,
64
+ message: string,
65
+ ): FastifyReply {
66
+ return reply.status(status).send({ ok: false, error: { code, message } });
67
+ }
68
+
69
+ function notFound(reply: FastifyReply): FastifyReply {
70
+ return error(reply, 404, "not_found", "Not found");
71
+ }
72
+
73
+ /** A single string header value, or `undefined` for an absent or repeated one. */
74
+ function singleHeader(
75
+ request: FastifyRequest,
76
+ name: string,
77
+ ): string | undefined {
78
+ const value = request.headers[name];
79
+ return typeof value === "string" ? value : undefined;
80
+ }
81
+
82
+ /** Send a service result: its refusal, or its value re-validated against `schema`. */
83
+ function respond<T>(
84
+ reply: FastifyReply,
85
+ result: Result<T>,
86
+ schema: ZodType<T>,
87
+ route: string,
88
+ successStatus: number,
89
+ ): FastifyReply {
90
+ if (!result.ok) {
91
+ const { status, code, message, retryAfter } = result.error;
92
+ if (retryAfter !== undefined)
93
+ reply.header("Retry-After", String(retryAfter));
94
+ return error(reply, status, code, message);
95
+ }
96
+ const parsed = schema.safeParse(result.value);
97
+ if (!parsed.success) {
98
+ // Issues only — never the value, which may carry a code or a credential.
99
+ logger.error(
100
+ { route, issues: parsed.error.issues.map((i) => i.path.join(".")) },
101
+ "[hub-sign-in] response failed its own contract schema",
102
+ );
103
+ return error(
104
+ reply,
105
+ 500,
106
+ "internal_error",
107
+ "Response failed contract validation",
108
+ );
109
+ }
110
+ return reply
111
+ .status(successStatus)
112
+ .header("Cache-Control", "no-store")
113
+ .header("Pragma", "no-cache")
114
+ .send({ ok: true, data: parsed.data });
115
+ }
116
+
117
+ /** `codes` and `me` — registered in `server.ts`'s `authScope`. */
118
+ export const hubSignInSessionRoutes: FastifyPluginAsync = async (fastify) => {
119
+ fastify.post(
120
+ "/v1/hub-sign-in/codes",
121
+ { bodyLimit: BODY_LIMIT_BYTES },
122
+ async (request, reply) => {
123
+ const userId = request.userId;
124
+ if (!userId) {
125
+ return error(reply, 401, "unauthorized", "Authentication required");
126
+ }
127
+ // A cookie session only. A personal ApiToken reaches here with a userId
128
+ // and no sessionTenant; it must mint nothing (D5).
129
+ if (!request.sessionTenant) {
130
+ return error(reply, 403, "forbidden", "Forbidden");
131
+ }
132
+ const result = await createCode({ userId, body: request.body });
133
+ return respond(
134
+ reply,
135
+ result,
136
+ hubSignInCodeResponseSchema,
137
+ "hub-sign-in/codes",
138
+ 201,
139
+ );
140
+ },
141
+ );
142
+
143
+ fastify.get("/v1/hub-sign-in/me", async (request, reply) => {
144
+ // Reachable only by the hub credential: `hubSession` is set by that one
145
+ // preHandler branch and by nothing else.
146
+ if (!request.hubSession || !request.userId) return notFound(reply);
147
+ const result = await readMe(
148
+ request.userId,
149
+ request.hubSession.workspaceId,
150
+ request.log,
151
+ );
152
+ return respond(
153
+ reply,
154
+ result,
155
+ hubSignInMeResponseSchema,
156
+ "hub-sign-in/me",
157
+ 200,
158
+ );
159
+ });
160
+ };
161
+
162
+ /** `token` and `revoke` — registered in their own rate-limited scope. */
163
+ export const hubSignInClientRoutes: FastifyPluginAsync = async (fastify) => {
164
+ fastify.post(
165
+ "/v1/hub-sign-in/token",
166
+ { bodyLimit: BODY_LIMIT_BYTES },
167
+ async (request, reply) => {
168
+ const result = await exchange({
169
+ authorization: singleHeader(request, "authorization"),
170
+ spokeKey: singleHeader(request, SPOKE_KEY_HEADER),
171
+ body: request.body,
172
+ });
173
+ return respond(
174
+ reply,
175
+ result,
176
+ hubSignInTokenResponseSchema,
177
+ "hub-sign-in/token",
178
+ 200,
179
+ );
180
+ },
181
+ );
182
+
183
+ fastify.post(
184
+ "/v1/hub-sign-in/revoke",
185
+ { bodyLimit: BODY_LIMIT_BYTES },
186
+ async (request, reply) => {
187
+ const result = await revoke({
188
+ authorization: singleHeader(request, "authorization"),
189
+ spokeKey: singleHeader(request, SPOKE_KEY_HEADER),
190
+ body: request.body,
191
+ });
192
+ return respond(
193
+ reply,
194
+ result,
195
+ hubSignInRevokeResponseSchema,
196
+ "hub-sign-in/revoke",
197
+ 200,
198
+ );
199
+ },
200
+ );
201
+ };