create-ailk 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +124 -0
- package/dist/add-module.js +52 -7
- package/dist/add-route.js +8 -0
- package/dist/adopt.d.ts +74 -0
- package/dist/adopt.js +314 -0
- package/dist/cli.js +82 -2
- package/dist/derive-architecture.d.ts +22 -0
- package/dist/derive-architecture.js +58 -1
- package/dist/lib/declared-env.d.ts +142 -0
- package/dist/lib/declared-env.js +985 -0
- package/dist/lib/env-catalog.d.ts +17 -0
- package/dist/lib/env-catalog.js +234 -0
- package/dist/lib/extract-module-bundle.d.ts +9 -0
- package/dist/lib/extract-module-bundle.js +18 -1
- package/dist/lib/module-tier.d.ts +2 -0
- package/dist/lib/module-tier.js +4 -0
- package/dist/module-architecture.js +76 -0
- package/dist/parse-args.d.ts +5 -0
- package/dist/parse-args.js +17 -1
- package/dist/programmatic.d.ts +8 -0
- package/dist/programmatic.js +78 -9
- package/dist/seed-flows.d.ts +65 -0
- package/dist/seed-flows.js +350 -0
- package/dist/site-manifest.d.ts +43 -0
- package/dist/site-manifest.js +98 -3
- package/dist/site-roster.d.ts +13 -0
- package/dist/site-roster.js +22 -8
- package/dist/spoke-checkout.d.ts +42 -0
- package/dist/spoke-checkout.js +359 -0
- package/dist/surfaces.js +7 -0
- package/dist/sync-routes.js +23 -8
- package/dist/topology.d.ts +124 -9
- package/dist/topology.js +449 -40
- package/package.json +3 -3
- package/templates/.env.example +10 -0
- package/templates/apps/api/CLAUDE.md +19 -4
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
- package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
- package/templates/apps/api/src/bin/spokes.ts +62 -1
- package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
- package/templates/apps/api/src/config/index.ts +35 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +89 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
- package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
- package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
- package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
- package/templates/apps/api/src/lib/spoke-store.ts +25 -3
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
- package/templates/apps/api/src/middleware/auth.ts +126 -0
- package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
- package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +7 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +72 -1
- package/templates/apps/api/src/server.ts +105 -0
- package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
- package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +293 -0
- package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
- package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
- package/templates/apps/api/src/services/lead-routing.ts +131 -2
- package/templates/apps/api/src/services/person.ts +541 -0
- package/templates/apps/web/.env.example +58 -5
- package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
- package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
- package/templates/apps/web/app/[locale]/layout.tsx +28 -15
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
- package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
- package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
- package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
- package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +15 -0
- package/templates/apps/web/app/openapi.json/route.ts +3 -6
- package/templates/apps/web/auth.ts +29 -2
- package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
- package/templates/apps/web/components/signed-in-nav.tsx +17 -3
- package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
- package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
- package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
- package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
- package/templates/apps/web/lib/__tests__/relay.test.ts +27 -0
- package/templates/apps/web/lib/account-config.ts +130 -1
- package/templates/apps/web/lib/api-origin.generated.ts +11 -0
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
- package/templates/apps/web/lib/hub-session/config.ts +104 -0
- package/templates/apps/web/lib/hub-session/index.ts +23 -0
- package/templates/apps/web/lib/hub-session/seal.ts +82 -0
- package/templates/apps/web/lib/hub-session/session.ts +180 -0
- package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
- package/templates/apps/web/lib/listings-config.ts +31 -0
- package/templates/apps/web/lib/listings.tsx +7 -3
- package/templates/apps/web/lib/meeting-slugs.ts +11 -1
- package/templates/apps/web/lib/next-path.ts +35 -0
- package/templates/apps/web/lib/relay.ts +3 -1
- package/templates/apps/web/lib/server-api.ts +11 -1
- package/templates/apps/web/lib/topology.generated.ts +15 -5
- package/templates/apps/web/messages/en.json +32 -3
- package/templates/database/CHANGELOG.md +26 -0
- package/templates/database/__tests__/agents.db.test.ts +25 -5
- package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
- package/templates/database/__tests__/member-area.db.test.ts +245 -0
- package/templates/database/__tests__/person.db.test.ts +584 -0
- package/templates/database/auth/schema.prisma +3 -2
- package/templates/database/config/schema.prisma +13 -0
- package/templates/database/inbox/schema.prisma +98 -0
- package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
- package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
- package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
- package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
- package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
- package/templates/database/package.json +1 -1
- package/templates/database/scripts/person-backfill.sql +132 -0
- package/templates/database/tenancy.ts +53 -0
- package/templates/tools/check-declared-env.ts +37 -0
- package/templates/tools/declared-env.ts +1163 -0
- package/versions.json +35 -35
|
@@ -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
|
+
});
|
|
@@ -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.
|
|
@@ -389,6 +437,61 @@ async function authenticateAgent(
|
|
|
389
437
|
}
|
|
390
438
|
}
|
|
391
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
|
+
|
|
392
495
|
export type AuthPreHandlerOptions = {
|
|
393
496
|
/**
|
|
394
497
|
* The browser origins a cookie-authenticated write may come from — exact
|
|
@@ -451,6 +554,24 @@ export async function resolveOptionalCookieUser(
|
|
|
451
554
|
return { kind: "user", userId: session.userId as Id<"user"> };
|
|
452
555
|
}
|
|
453
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
|
+
|
|
454
575
|
/**
|
|
455
576
|
* Fastify preHandler implementing the dual-mode contract.
|
|
456
577
|
*
|
|
@@ -500,6 +621,10 @@ export function makeAuthPreHandler(opts: AuthPreHandlerOptions) {
|
|
|
500
621
|
}
|
|
501
622
|
|
|
502
623
|
const bearer = readBearer(request);
|
|
624
|
+
if (bearer?.startsWith(HUB_SESSION_PREFIX)) {
|
|
625
|
+
await authenticateHubSession(request, reply, bearer, now);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
503
628
|
if (bearer?.startsWith(AGENT_KEY_PREFIX)) {
|
|
504
629
|
await authenticateAgent(request, reply, bearer, now);
|
|
505
630
|
return;
|
|
@@ -550,6 +675,7 @@ export const authPreHandlerPlugin: FastifyPluginCallback<
|
|
|
550
675
|
fastify.decorateRequest("sessionTenant", undefined);
|
|
551
676
|
fastify.decorateRequest("agent", undefined);
|
|
552
677
|
fastify.decorateRequest("actor", undefined);
|
|
678
|
+
fastify.decorateRequest("hubSession", undefined);
|
|
553
679
|
fastify.addHook("preHandler", handler);
|
|
554
680
|
done();
|
|
555
681
|
};
|