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,310 @@
1
+ /**
2
+ * @file auth.hub-session-path.test.ts
3
+ *
4
+ * The hub-credential branch of the auth preHandler (#7894, spec
5
+ * docs/specs/7894-the-hub-signs-in-its-spec.md D6; AC-4, AC-5, AC-7).
6
+ *
7
+ * A bearer starting `ailk_hst_` is admitted only beside the SAME spoke's key,
8
+ * only while its grant is live and its person verified, and only on the four
9
+ * routes of `HUB_SESSION_ROUTES`. Any other route answers 404 with no handler
10
+ * run (the status `lib/account-config.ts`'s `read()` already loads as "nothing
11
+ * for this caller"), never 403.
12
+ *
13
+ * Stub routes stand in for #7877's three member reads, which are unmerged; each
14
+ * one records that its handler ran, so "404 with no handler run" is asserted,
15
+ * not assumed.
16
+ *
17
+ * External dependency boundary mocked:
18
+ * - @working-theory/database (via the prisma singleton; __mocks__/prisma.ts)
19
+ */
20
+
21
+ // 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.
22
+ jest.mock("../../lib/prisma.js");
23
+
24
+ import Fastify from "fastify";
25
+ import type { FastifyInstance } from "fastify";
26
+
27
+ import {
28
+ installHubSignInFakeDb,
29
+ seedSpoke,
30
+ seedUser,
31
+ type FakeHubDb,
32
+ type FakeUser,
33
+ type SeededSpoke,
34
+ } from "../../__tests__/support/hub-sign-in-fake-db.js";
35
+ import { mintCredential, sha256Hex } from "../../lib/hub-sign-in-store.js";
36
+ import { prisma } from "../../lib/prisma.js";
37
+ import { authPreHandlerPlugin, HUB_SESSION_ROUTES } from "../auth.js";
38
+
39
+ const db = jest.mocked(prisma);
40
+
41
+ const FRONTEND = "https://app.example.com";
42
+
43
+ let state: FakeHubDb;
44
+ let app: FastifyInstance;
45
+ let user: FakeUser;
46
+ let spokeA: SeededSpoke;
47
+ let spokeB: SeededSpoke;
48
+ let handlerRuns: string[];
49
+
50
+ /** A live credential for (user, spoke), stored the way the token route stores it. */
51
+ function grant(
52
+ s: SeededSpoke,
53
+ overrides: {
54
+ expiresAt?: Date;
55
+ revokedAt?: Date | null;
56
+ userId?: string;
57
+ } = {},
58
+ ): string {
59
+ const credential = mintCredential();
60
+ state.authorizations.push({
61
+ id: `oautha_${state.authorizations.length + 1}`,
62
+ userId: overrides.userId ?? user.id,
63
+ clientId: s.client.id,
64
+ scopes: ["account"],
65
+ accessToken: sha256Hex(credential),
66
+ expiresAt: overrides.expiresAt ?? new Date(Date.now() + 60 * 60 * 1000),
67
+ revokedAt: overrides.revokedAt ?? null,
68
+ });
69
+ return credential;
70
+ }
71
+
72
+ const headers = (credential: string, s: SeededSpoke = spokeA) => ({
73
+ authorization: `Bearer ${credential}`,
74
+ "x-ailk-spoke-key": s.key,
75
+ });
76
+
77
+ function buildApp(): FastifyInstance {
78
+ const server = Fastify();
79
+ server.register(authPreHandlerPlugin, { trustedOrigins: [FRONTEND] });
80
+
81
+ const echo =
82
+ (name: string) => async (req: import("fastify").FastifyRequest) => {
83
+ handlerRuns.push(name);
84
+ return {
85
+ userId: req.userId ?? null,
86
+ hubSession: req.hubSession ?? null,
87
+ sessionTenant: req.sessionTenant ?? null,
88
+ actor: req.actor ?? null,
89
+ };
90
+ };
91
+
92
+ // The four admitted routes (the member reads are stubs).
93
+ server.get("/v1/hub-sign-in/me", echo("me"));
94
+ server.get("/v1/member/pieces", echo("pieces"));
95
+ server.get("/v1/member/messages", echo("messages"));
96
+ server.get("/v1/member/messages/:id/file", echo("file"));
97
+
98
+ // Everything else the credential must NOT reach.
99
+ server.get("/v1/member/other", echo("other"));
100
+ server.get("/v1/member/messages/:id", echo("message-item"));
101
+ server.get("/v1/waitlist-signups/me", echo("waitlist-signups"));
102
+ server.get("/v1/flow-checkouts/me", echo("flow-checkouts"));
103
+ server.get("/v1/subscription/me", echo("subscription"));
104
+ server.post("/v1/subscription/portal-session", echo("portal"));
105
+ server.get("/v1/auth/tokens", echo("tokens"));
106
+ server.patch("/v1/user/profile", echo("profile"));
107
+ server.post("/v1/hub-sign-in/codes", echo("codes"));
108
+ return server;
109
+ }
110
+
111
+ beforeEach(async () => {
112
+ jest.resetAllMocks();
113
+ state = installHubSignInFakeDb(db);
114
+ handlerRuns = [];
115
+ user = seedUser(state);
116
+ spokeA = seedSpoke(state, "a.example.com");
117
+ spokeB = seedSpoke(state, "b.example.com");
118
+ app = buildApp();
119
+ await app.ready();
120
+ });
121
+
122
+ afterEach(async () => {
123
+ await app.close();
124
+ });
125
+
126
+ describe("HUB_SESSION_ROUTES", () => {
127
+ it("is exactly the four reads, by method and registered URL — never a prefix", () => {
128
+ expect([...HUB_SESSION_ROUTES]).toEqual([
129
+ "GET /v1/hub-sign-in/me",
130
+ "GET /v1/member/pieces",
131
+ "GET /v1/member/messages",
132
+ "GET /v1/member/messages/:id/file",
133
+ ]);
134
+ });
135
+ });
136
+
137
+ describe("hub credential — admitted routes", () => {
138
+ it.each([
139
+ ["GET", "/v1/hub-sign-in/me", "me"],
140
+ ["GET", "/v1/member/pieces", "pieces"],
141
+ ["GET", "/v1/member/messages", "messages"],
142
+ ["GET", "/v1/member/messages/lead_1/file", "file"],
143
+ ])(
144
+ "%s %s is admitted as the credential's person, bound to the spoke",
145
+ async (method, url, name) => {
146
+ const credential = grant(spokeA);
147
+
148
+ const res = await app.inject({
149
+ method: method as "GET",
150
+ url,
151
+ headers: headers(credential),
152
+ });
153
+
154
+ expect(res.statusCode).toBe(200);
155
+ expect(handlerRuns).toEqual([name]);
156
+ const body = res.json();
157
+ expect(body.userId).toBe(user.id);
158
+ expect(body.hubSession).toEqual({
159
+ spokeId: spokeA.spoke.id,
160
+ workspaceId: spokeA.spoke.workspaceId,
161
+ });
162
+ // Acts as its person; never carries a cookie session's tenant.
163
+ expect(body.actor).toEqual({ type: "user", userId: user.id });
164
+ expect(body.sessionTenant).toBeNull();
165
+ },
166
+ );
167
+ });
168
+
169
+ describe("hub credential — every other route is 404 with no handler run (AC-7)", () => {
170
+ it.each([
171
+ ["GET", "/v1/member/other"],
172
+ ["GET", "/v1/member/messages/lead_1"],
173
+ ["GET", "/v1/waitlist-signups/me"],
174
+ ["GET", "/v1/flow-checkouts/me"],
175
+ ["GET", "/v1/subscription/me"],
176
+ ["POST", "/v1/subscription/portal-session"],
177
+ ["GET", "/v1/auth/tokens"],
178
+ ["PATCH", "/v1/user/profile"],
179
+ ["POST", "/v1/hub-sign-in/codes"],
180
+ ])("%s %s", async (method, url) => {
181
+ const credential = grant(spokeA);
182
+
183
+ const res = await app.inject({
184
+ method: method as "GET",
185
+ url,
186
+ headers: headers(credential),
187
+ });
188
+
189
+ expect(res.statusCode).toBe(404);
190
+ expect(res.json()).toEqual({
191
+ ok: false,
192
+ error: { code: "not_found", message: "Not found" },
193
+ });
194
+ expect(handlerRuns).toEqual([]);
195
+ });
196
+
197
+ it("the list is method-exact: HEAD on an admitted URL is not admitted", async () => {
198
+ const credential = grant(spokeA);
199
+ const res = await app.inject({
200
+ method: "HEAD",
201
+ url: "/v1/hub-sign-in/me",
202
+ headers: headers(credential),
203
+ });
204
+ expect(res.statusCode).toBe(404);
205
+ expect(handlerRuns).toEqual([]);
206
+ });
207
+ });
208
+
209
+ describe("hub credential — refused with 401 before any route check", () => {
210
+ const me = (h: Record<string, string>) =>
211
+ app.inject({ method: "GET", url: "/v1/hub-sign-in/me", headers: h });
212
+
213
+ it("AC-4: spoke A's credential sent with spoke B's key", async () => {
214
+ const credential = grant(spokeA);
215
+ expect((await me(headers(credential, spokeB))).statusCode).toBe(401);
216
+ expect(handlerRuns).toEqual([]);
217
+ });
218
+
219
+ it("the credential with no spoke key at all", async () => {
220
+ const credential = grant(spokeA);
221
+ expect(
222
+ (await me({ authorization: `Bearer ${credential}` })).statusCode,
223
+ ).toBe(401);
224
+ });
225
+
226
+ it("an unknown spoke key, and a revoked spoke", async () => {
227
+ const credential = grant(spokeA);
228
+ expect(
229
+ (
230
+ await me({
231
+ authorization: `Bearer ${credential}`,
232
+ "x-ailk-spoke-key": "ailk_spk_nope",
233
+ })
234
+ ).statusCode,
235
+ ).toBe(401);
236
+ spokeA.spoke.revokedAt = new Date();
237
+ expect((await me(headers(credential))).statusCode).toBe(401);
238
+ });
239
+
240
+ it("an unknown credential", async () => {
241
+ expect((await me(headers(mintCredential()))).statusCode).toBe(401);
242
+ });
243
+
244
+ it("a revoked credential", async () => {
245
+ const credential = grant(spokeA, { revokedAt: new Date() });
246
+ expect((await me(headers(credential))).statusCode).toBe(401);
247
+ });
248
+
249
+ it("an expired credential", async () => {
250
+ const credential = grant(spokeA, {
251
+ expiresAt: new Date(Date.now() - 1_000),
252
+ });
253
+ expect((await me(headers(credential))).statusCode).toBe(401);
254
+ });
255
+
256
+ it("a credential whose client was disabled", async () => {
257
+ const credential = grant(spokeA);
258
+ spokeA.client.enabled = false;
259
+ expect((await me(headers(credential))).statusCode).toBe(401);
260
+ });
261
+
262
+ it("AC-5: a credential whose person's email is no longer verified", async () => {
263
+ const credential = grant(spokeA);
264
+ user.emailVerified = false;
265
+ expect((await me(headers(credential))).statusCode).toBe(401);
266
+ expect(handlerRuns).toEqual([]);
267
+ });
268
+
269
+ it("answers the preHandler's existing 401 body", async () => {
270
+ const res = await me(headers(mintCredential()));
271
+ expect(res.json()).toEqual({ error: "unauthorized" });
272
+ });
273
+ });
274
+
275
+ describe("hub credential — it is a bearer and nothing else", () => {
276
+ it("AC-5: sent as the better-auth session cookie it is 401 (no Session row matches)", async () => {
277
+ const credential = grant(spokeA);
278
+ const res = await app.inject({
279
+ method: "GET",
280
+ url: "/v1/hub-sign-in/me",
281
+ headers: {
282
+ cookie: `better-auth.session_token=${credential}`,
283
+ "x-ailk-spoke-key": spokeA.key,
284
+ },
285
+ });
286
+ expect(res.statusCode).toBe(401);
287
+ expect(handlerRuns).toEqual([]);
288
+ });
289
+
290
+ it("is not looked up as a personal ApiToken, and takes no part in the agent branch", async () => {
291
+ const credential = grant(spokeA);
292
+ await app.inject({
293
+ method: "GET",
294
+ url: "/v1/hub-sign-in/me",
295
+ headers: headers(credential),
296
+ });
297
+ expect(db.apiToken.findUnique).not.toHaveBeenCalled();
298
+ expect(db.agent.findUnique).not.toHaveBeenCalled();
299
+ });
300
+
301
+ it("is never logged: a rejected request leaves the credential out of the body", async () => {
302
+ const credential = grant(spokeA, { revokedAt: new Date() });
303
+ const res = await app.inject({
304
+ method: "GET",
305
+ url: "/v1/hub-sign-in/me",
306
+ headers: headers(credential),
307
+ });
308
+ expect(res.body).not.toContain(credential);
309
+ });
310
+ });
@@ -530,6 +530,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
530
530
  workspaceId: string;
531
531
  organizationId: string;
532
532
  responsibleMembershipId: string;
533
+ grants: string[];
533
534
  }> = {},
534
535
  ) {
535
536
  return {
@@ -539,6 +540,7 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
539
540
  responsibleMembershipId: (overrides.responsibleMembershipId ??
540
541
  OPERATOR_MEMBERSHIP_ID) as Id<"membership">,
541
542
  permissionLevel: "member" as const,
543
+ grants: overrides.grants ?? [],
542
544
  };
543
545
  }
544
546
 
@@ -582,6 +584,26 @@ describe("defaultTenantPreHandler — agents (#3591 D5, D7)", () => {
582
584
  await app.close();
583
585
  });
584
586
 
587
+ it("a granted agent of the owner-operator carries its grants on the tenant; a forged one is dropped (#7864 D3)", async () => {
588
+ mockSingleTenantWorkspace();
589
+ mockOperatorMembership();
590
+ const app = buildAgentApp(
591
+ agentBinding({ grants: ["magnets-manage", "agents-manage", "content-edit"] }),
592
+ );
593
+
594
+ const res = await app.inject({ method: "GET", url: "/scoped" });
595
+
596
+ expect(res.statusCode).toBe(200);
597
+ expect(res.json().tenant).toEqual({
598
+ userId: USER_ID,
599
+ organizationId: DEFAULT_ORG_ID,
600
+ workspaceId: DEFAULT_WORKSPACE_ID,
601
+ role: "member",
602
+ grants: ["content-edit", "magnets-manage"],
603
+ });
604
+ await app.close();
605
+ });
606
+
585
607
  it("reaches the grant through the operator's own reads: the same membership compound key", async () => {
586
608
  mockSingleTenantWorkspace();
587
609
  mockOperatorMembership();
@@ -28,6 +28,16 @@
28
28
  * limited to 60 requests a minute, at most 10 of them writes (any
29
29
  * method but GET and HEAD).
30
30
  *
31
+ * 4. Hub-credential path (#7894) — a bearer starting `ailk_hst_` is a
32
+ * credential a spoke holds for a person the hub signed in on its behalf
33
+ * (`lib/hub-sign-in-store.ts`). It is admitted only beside the SAME
34
+ * spoke's key (`x-ailk-spoke-key`), only while its grant is unrevoked and
35
+ * unexpired, its client enabled and its person's email verified, and only
36
+ * on `HUB_SESSION_ROUTES` — an exact list of four reads; any other route
37
+ * is 404 (never 403, so the account area's `read()` loads "nothing for
38
+ * this caller"). It acts as its person (`request.userId`) and carries
39
+ * `request.hubSession` — never `request.sessionTenant`.
40
+ *
31
41
  * Every authenticated request carries `request.actor` — the person, or the
32
42
  * agent and the person it acts for — and its request/reply loggers are
33
43
  * rebound to name it, so Fastify's "request completed" line says who acted.
@@ -61,7 +71,12 @@ import {
61
71
  touchAgentLastActive,
62
72
  type AgentGrant,
63
73
  } from "../lib/agent-store.js";
74
+ import {
75
+ findAuthorizationByCredentialHash,
76
+ HUB_SESSION_PREFIX,
77
+ } from "../lib/hub-sign-in-store.js";
64
78
  import { prisma } from "../lib/prisma.js";
79
+ import { findSpokeByKeyHash, hashSpokeKey } from "../lib/spoke-store.js";
65
80
 
66
81
  /**
67
82
  * Better Auth's default cookie names. Both are tried in order so a deployment
@@ -74,6 +89,11 @@ const COOKIE_NAMES = [
74
89
 
75
90
  const UNAUTHORIZED = { error: "unauthorized" } as const;
76
91
  const DATABASE_NOT_CONFIGURED = { error: "database_not_configured" } as const;
92
+ const NOT_FOUND = {
93
+ ok: false,
94
+ error: { code: "not_found", message: "Not found" },
95
+ } as const;
96
+ const SPOKE_KEY_HEADER = "x-ailk-spoke-key";
77
97
  const FORBIDDEN = {
78
98
  ok: false,
79
99
  error: { code: "forbidden", message: "Forbidden" },
@@ -98,6 +118,11 @@ declare module "fastify" {
98
118
  agent?: AgentGrant;
99
119
  /** Who made the request — set on every authenticated request (#3591 D9). */
100
120
  actor?: RequestActor;
121
+ /**
122
+ * Set ONLY on the hub-credential path (#7894 D6): the spoke the credential
123
+ * and the request's spoke key both name, and that spoke's workspace.
124
+ */
125
+ hubSession?: HubSession;
101
126
  }
102
127
  interface FastifyContextConfig {
103
128
  /**
@@ -109,6 +134,29 @@ declare module "fastify" {
109
134
  }
110
135
  }
111
136
 
137
+ /** The spoke a hub-issued credential was admitted for (#7894 D6). */
138
+ export type HubSession = {
139
+ spokeId: Id<"spoke">;
140
+ workspaceId: Id<"workspace">;
141
+ };
142
+
143
+ /**
144
+ * The only routes a hub-issued credential reaches (#7894 D6): an EXACT list of
145
+ * method + registered URL, never a prefix, so a route added later joins only
146
+ * by a reviewed edit of this list (SEC-7894-3). The first is the credential's
147
+ * own `me`; the other three are #7877's member reads. Not admitted,
148
+ * deliberately: `GET /v1/waitlist-signups/me`, `GET /v1/flow-checkouts/me`,
149
+ * `GET /v1/subscription/me` and `POST /v1/subscription/portal-session` (they
150
+ * answer for the HUB's site and the person's platform billing, SEC-7894-1,
151
+ * -2), and the token and profile routes.
152
+ */
153
+ export const HUB_SESSION_ROUTES: readonly string[] = [
154
+ "GET /v1/hub-sign-in/me",
155
+ "GET /v1/member/pieces",
156
+ "GET /v1/member/messages",
157
+ "GET /v1/member/messages/:id/file",
158
+ ];
159
+
112
160
  /**
113
161
  * Who made a request (#3591 D9). A cookie session and a personal `ApiToken`
114
162
  * act as their person; an agent acts on behalf of its responsible person.
@@ -354,6 +402,9 @@ async function authenticateAgent(
354
402
  organizationId: row.organizationId as Id<"organization">,
355
403
  responsibleMembershipId: row.responsible.id as Id<"membership">,
356
404
  permissionLevel: row.permissionLevel,
405
+ // #7864 D3 — the one place a missing value becomes none; the tenant
406
+ // guards keep only the grants its person's role also holds.
407
+ grants: row.grants ?? [],
357
408
  };
358
409
  bindActor(request, reply, {
359
410
  type: "agent",
@@ -386,6 +437,61 @@ async function authenticateAgent(
386
437
  }
387
438
  }
388
439
 
440
+ /**
441
+ * The hub-credential path (#7894 D6). The credential is never logged and
442
+ * never echoed: every refusal is a fixed body. (1)-(3) failing is the one 401;
443
+ * (4), the route not being on `HUB_SESSION_ROUTES`, is 404 with no handler run.
444
+ */
445
+ async function authenticateHubSession(
446
+ request: FastifyRequest,
447
+ reply: FastifyReply,
448
+ credential: string,
449
+ now: number,
450
+ ): Promise<void> {
451
+ // (1) The spoke key resolves to an unrevoked spoke.
452
+ const rawKey = request.headers[SPOKE_KEY_HEADER];
453
+ const spokeKey = typeof rawKey === "string" ? rawKey.trim() : "";
454
+ const spoke = spokeKey
455
+ ? await findSpokeByKeyHash(hashSpokeKey(spokeKey)).catch(() => null)
456
+ : null;
457
+ if (!spoke || spoke.revokedAt) {
458
+ reply.code(401).send(UNAUTHORIZED);
459
+ return;
460
+ }
461
+
462
+ // (2) The credential's grant is live, and its client IS this spoke's.
463
+ // (3) Its person's email is verified (read in the same query).
464
+ const grant = await findAuthorizationByCredentialHash(
465
+ hashBearer(credential),
466
+ ).catch(() => null);
467
+ if (
468
+ !grant ||
469
+ grant.revokedAt ||
470
+ grant.expiresAt.getTime() <= now ||
471
+ !grant.client.enabled ||
472
+ grant.client.clientId !== spoke.id ||
473
+ grant.user.emailVerified !== true
474
+ ) {
475
+ reply.code(401).send(UNAUTHORIZED);
476
+ return;
477
+ }
478
+
479
+ // (4) Method and registered URL are on the list.
480
+ const route = `${request.method.toUpperCase()} ${request.routeOptions.url ?? ""}`;
481
+ if (!HUB_SESSION_ROUTES.includes(route)) {
482
+ reply.code(404).send(NOT_FOUND);
483
+ return;
484
+ }
485
+
486
+ // Brand assertion at the Prisma read edge (ADR-0031 rule 5), as above.
487
+ request.userId = grant.userId as Id<"user">;
488
+ request.hubSession = {
489
+ spokeId: spoke.id as Id<"spoke">,
490
+ workspaceId: spoke.workspaceId as Id<"workspace">,
491
+ };
492
+ bindActor(request, reply, { type: "user", userId: request.userId });
493
+ }
494
+
389
495
  export type AuthPreHandlerOptions = {
390
496
  /**
391
497
  * The browser origins a cookie-authenticated write may come from — exact
@@ -448,6 +554,24 @@ export async function resolveOptionalCookieUser(
448
554
  return { kind: "user", userId: session.userId as Id<"user"> };
449
555
  }
450
556
 
557
+ /**
558
+ * The person a member read answers for (#7877 D5): the hub's own cookie
559
+ * session or a hub-issued credential (#7894 D6) — never a personal ApiToken,
560
+ * never an agent. `null` → the route answers 401 with `UNAUTHORIZED`.
561
+ *
562
+ * This is the ONE place the member reads (`services/member-area.ts`) learn
563
+ * who is calling: a later credential kind joins them by an edit of this
564
+ * function and nothing in `routes/member/`. An agent never gets here — the
565
+ * member routes are not tenant-guarded, so this preHandler answers it 403.
566
+ */
567
+ export function memberCaller(request: FastifyRequest): Id<"user"> | null {
568
+ if (request.userId === undefined) return null;
569
+ if (request.sessionTenant !== undefined || request.hubSession !== undefined) {
570
+ return request.userId;
571
+ }
572
+ return null;
573
+ }
574
+
451
575
  /**
452
576
  * Fastify preHandler implementing the dual-mode contract.
453
577
  *
@@ -497,6 +621,10 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
497
621
  }
498
622
 
499
623
  const bearer = readBearer(request);
624
+ if (bearer?.startsWith(HUB_SESSION_PREFIX)) {
625
+ await authenticateHubSession(request, reply, bearer, now);
626
+ return;
627
+ }
500
628
  if (bearer?.startsWith(AGENT_KEY_PREFIX)) {
501
629
  await authenticateAgent(request, reply, bearer, now);
502
630
  return;
@@ -547,6 +675,7 @@ export const authPreHandlerPlugin: FastifyPluginCallback<
547
675
  fastify.decorateRequest("sessionTenant", undefined);
548
676
  fastify.decorateRequest("agent", undefined);
549
677
  fastify.decorateRequest("actor", undefined);
678
+ fastify.decorateRequest("hubSession", undefined);
550
679
  fastify.addHook("preHandler", handler);
551
680
  done();
552
681
  };
@@ -222,6 +222,29 @@ describe("GET /api/openapi.json", () => {
222
222
  },
223
223
  );
224
224
 
225
+ it("documents /v1/leads/submit's optional Idempotency-Key header and its 422 (#7869)", async () => {
226
+ const res = await app.inject({
227
+ method: "GET",
228
+ url: "/v1/openapi.json",
229
+ });
230
+
231
+ const spec = JSON.parse(res.body);
232
+ const post = spec.paths["/v1/leads/submit"].post;
233
+ expect(post.parameters).toEqual(
234
+ expect.arrayContaining([
235
+ expect.objectContaining({
236
+ name: "Idempotency-Key",
237
+ in: "header",
238
+ required: false,
239
+ }),
240
+ ]),
241
+ );
242
+ expect(
243
+ post.responses["422"].content["application/json"].schema.properties.error
244
+ .enum,
245
+ ).toEqual(["idempotency_key_reused"]);
246
+ });
247
+
225
248
  it("no longer describes the API as read-only", async () => {
226
249
  const res = await app.inject({
227
250
  method: "GET",
@@ -751,7 +751,25 @@ export function buildOpenApiSpec(
751
751
  "only), or, on a deployment with no database, the site operator's inbox " +
752
752
  "by email (`persisted: \"email\"`); with a database, the response's " +
753
753
  "`email` field reports, best-effort, whether the operator was notified. " +
754
- "A lead is never reported captured when nothing stored or delivered it.",
754
+ "A lead is never reported captured when nothing stored or delivered it. " +
755
+ "An optional `Idempotency-Key` header makes a retry safe: with a " +
756
+ "database, a repeat of a stored key in the same workspace with the same " +
757
+ "`email`, `source` and `payload` writes nothing and answers the first " +
758
+ "lead's id with a 201; with anything different it is a 422. Keys are " +
759
+ "scoped per workspace, are first-write-wins, and should be unguessable " +
760
+ "(a UUID, a Stripe Checkout Session id). Without a database there is no " +
761
+ "stored lead to match, so the key has no effect.",
762
+ parameters: [
763
+ {
764
+ name: "Idempotency-Key",
765
+ in: "header",
766
+ required: false,
767
+ description:
768
+ "An unguessable value identifying this lead write, so a retry " +
769
+ "answers the first lead instead of writing a second (#7869).",
770
+ schema: { type: "string", pattern: "^[\\x21-\\x7E]{16,255}$" },
771
+ },
772
+ ],
755
773
  requestBody: {
756
774
  required: true,
757
775
  content: {
@@ -770,13 +788,31 @@ export function buildOpenApiSpec(
770
788
  },
771
789
  },
772
790
  "400": {
773
- description: "Validation error — invalid request body",
791
+ description:
792
+ "Validation error — invalid request body, or a malformed Idempotency-Key",
774
793
  content: {
775
794
  "application/json": {
776
795
  schema: { $ref: "#/components/schemas/InvalidInputResponse" },
777
796
  },
778
797
  },
779
798
  },
799
+ "422": {
800
+ description:
801
+ "The Idempotency-Key was already used in this workspace for a lead " +
802
+ "with a different email, source or payload; nothing was written",
803
+ content: {
804
+ "application/json": {
805
+ schema: {
806
+ type: "object",
807
+ required: ["error", "message"],
808
+ properties: {
809
+ error: { type: "string", enum: ["idempotency_key_reused"] },
810
+ message: { type: "string" },
811
+ },
812
+ },
813
+ },
814
+ },
815
+ },
780
816
  "503": {
781
817
  description:
782
818
  "Lead capture unavailable — persistence failed, delivery failed, or " +