@cosmicdrift/kumiko-framework 0.297.0 → 0.299.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/package.json +4 -4
- package/src/api/__tests__/body-limit.test.ts +4 -4
- package/src/api/__tests__/extra-routes.integration.test.ts +584 -0
- package/src/api/extra-route.ts +146 -0
- package/src/api/index.ts +16 -1
- package/src/api/server.ts +317 -52
- package/src/changes.json +11 -0
- package/src/engine/system-user.ts +5 -5
- package/src/entrypoint/index.ts +2 -0
- package/src/stack/test-stack.ts +8 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.299.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.299.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.299.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.299.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
|
@@ -164,10 +164,10 @@ describe("default coverage sweep — proves the default, not a hand-maintained l
|
|
|
164
164
|
});
|
|
165
165
|
|
|
166
166
|
// Regression for the DoD: "neue Route ohne Eintrag in irgendeiner Liste
|
|
167
|
-
// bekommt automatisch ein Limit". Mounts a route
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
167
|
+
// bekommt automatisch ein Limit". Mounts a route directly on the app
|
|
168
|
+
// after buildServer, with zero Routes/opt-out entries — and proves it
|
|
169
|
+
// inherits the cap AND still serves a small body correctly (not an
|
|
170
|
+
// accidental always-413).
|
|
171
171
|
test("a route mounted after buildServer with no list entry anywhere still inherits the default limit", async () => {
|
|
172
172
|
const app = buildApp();
|
|
173
173
|
app.post("/api/totally-new-route-nobody-listed", async (c) => c.json({ ok: true }));
|
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
// Full-stack proof for the declarative extraRoutes API (kumiko-framework#3050):
|
|
2
|
+
// each `entry` tier gets the matching guard + deps from buildServer, driven
|
|
3
|
+
// via real HTTP (setupTestStack + app.request), never createTestDispatcher.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { createHmac } from "node:crypto";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { createRegistry, defineFeature, type TenantId } from "../../engine";
|
|
9
|
+
import { RateLimitError } from "../../errors";
|
|
10
|
+
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
11
|
+
import {
|
|
12
|
+
type AnonymousExtraRoute,
|
|
13
|
+
type ExtraRouteDefinition,
|
|
14
|
+
ExtraRouteRejection,
|
|
15
|
+
type SignatureExtraRoute,
|
|
16
|
+
signatureRoute,
|
|
17
|
+
type UserExtraRoute,
|
|
18
|
+
} from "../extra-route";
|
|
19
|
+
import { buildServer } from "../server";
|
|
20
|
+
|
|
21
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
22
|
+
const JWT_SECRET = "test-extra-routes-secret-32-chars-min!!";
|
|
23
|
+
|
|
24
|
+
const probeFeature = defineFeature("extra-route-probe", (r) => {
|
|
25
|
+
r.queryHandler({
|
|
26
|
+
name: "ping",
|
|
27
|
+
schema: z.object({}),
|
|
28
|
+
access: { roles: ["anonymous"] },
|
|
29
|
+
rateLimit: { per: "ip", limit: 2, windowSeconds: 60 },
|
|
30
|
+
handler: async () => ({ pong: true }),
|
|
31
|
+
});
|
|
32
|
+
r.writeHandler({
|
|
33
|
+
name: "self-write",
|
|
34
|
+
schema: z.object({ note: z.string() }),
|
|
35
|
+
access: { roles: ["User", "Admin"] },
|
|
36
|
+
handler: async (event) => ({
|
|
37
|
+
isSuccess: true as const,
|
|
38
|
+
data: { userId: event.user.id, note: event.payload.note },
|
|
39
|
+
}),
|
|
40
|
+
});
|
|
41
|
+
r.writeHandler({
|
|
42
|
+
name: "admin-only-write",
|
|
43
|
+
schema: z.object({}),
|
|
44
|
+
access: { roles: ["Admin"] },
|
|
45
|
+
handler: async () => ({ isSuccess: true as const, data: { ok: true as const } }),
|
|
46
|
+
});
|
|
47
|
+
r.writeHandler({
|
|
48
|
+
name: "hmac-write",
|
|
49
|
+
schema: z.object({ note: z.string() }),
|
|
50
|
+
access: { roles: ["SystemAdmin"] },
|
|
51
|
+
handler: async (event) => ({
|
|
52
|
+
isSuccess: true as const,
|
|
53
|
+
data: { tenantSeen: event.user.tenantId, note: event.payload.note },
|
|
54
|
+
}),
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Module-level store — proves deps.write ran via a follow-up query, no DB needed.
|
|
59
|
+
const anonymousWriteStore = new Map<string, string[]>();
|
|
60
|
+
|
|
61
|
+
const anonymousWriteFeature = defineFeature("anonymous-write-probe", (r) => {
|
|
62
|
+
r.writeHandler({
|
|
63
|
+
name: "anon-write",
|
|
64
|
+
schema: z.object({ note: z.string() }),
|
|
65
|
+
access: { roles: ["anonymous"] },
|
|
66
|
+
handler: async (event) => {
|
|
67
|
+
const notes = anonymousWriteStore.get(event.user.tenantId) ?? [];
|
|
68
|
+
notes.push(event.payload.note);
|
|
69
|
+
anonymousWriteStore.set(event.user.tenantId, notes);
|
|
70
|
+
return { isSuccess: true as const, data: { tenantSeen: event.user.tenantId } };
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
r.writeHandler({
|
|
74
|
+
name: "anon-gated-write",
|
|
75
|
+
schema: z.object({}),
|
|
76
|
+
access: { roles: ["Admin"] },
|
|
77
|
+
handler: async () => ({ isSuccess: true as const, data: { ok: true as const } }),
|
|
78
|
+
});
|
|
79
|
+
r.queryHandler({
|
|
80
|
+
name: "anon-notes",
|
|
81
|
+
schema: z.object({}),
|
|
82
|
+
access: { roles: ["anonymous"] },
|
|
83
|
+
handler: async (event) => ({ notes: anonymousWriteStore.get(event.user.tenantId) ?? [] }),
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("extraRoutes: entry:anonymous", () => {
|
|
88
|
+
const pingRoute: AnonymousExtraRoute = {
|
|
89
|
+
method: "GET",
|
|
90
|
+
path: "/public/ping",
|
|
91
|
+
entry: "anonymous",
|
|
92
|
+
handler: async (c, deps) => {
|
|
93
|
+
try {
|
|
94
|
+
const result = await deps.systemQuery("extra-route-probe:query:ping", {}, TENANT_ID);
|
|
95
|
+
return c.json(result as { pong: boolean }); // @cast-boundary engine-payload
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e instanceof RateLimitError) {
|
|
98
|
+
return c.json({ error: { code: e.code } }, e.httpStatus);
|
|
99
|
+
}
|
|
100
|
+
throw e;
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
let stack: TestStack;
|
|
106
|
+
|
|
107
|
+
beforeAll(async () => {
|
|
108
|
+
stack = await setupTestStack({ features: [probeFeature], extraRoutes: [pingRoute] });
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
afterAll(() => stack.cleanup());
|
|
112
|
+
|
|
113
|
+
test("reachable without any session, outside /api", async () => {
|
|
114
|
+
const res = await stack.app.request("/public/ping", {
|
|
115
|
+
headers: { "x-forwarded-for": "203.0.113.10" },
|
|
116
|
+
});
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
expect(await res.json()).toEqual({ pong: true });
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("systemQuery wraps requestContext so a handler's per-ip rateLimit actually fires", async () => {
|
|
122
|
+
const ip = "203.0.113.20";
|
|
123
|
+
for (let i = 0; i < 2; i++) {
|
|
124
|
+
const res = await stack.app.request("/public/ping", { headers: { "x-forwarded-for": ip } });
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
}
|
|
127
|
+
const limited = await stack.app.request("/public/ping", { headers: { "x-forwarded-for": ip } });
|
|
128
|
+
expect(limited.status).toBe(429);
|
|
129
|
+
const body = (await limited.json()) as { error: { code: string } };
|
|
130
|
+
expect(body.error.code).toBe("rate_limited");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("extraRoutes: entry:anonymous under /api/* (kumiko-framework#3050 bypass fix)", () => {
|
|
135
|
+
const apiPingRoute: AnonymousExtraRoute = {
|
|
136
|
+
method: "GET",
|
|
137
|
+
path: "/api/public-ping",
|
|
138
|
+
entry: "anonymous",
|
|
139
|
+
handler: async (c) => c.json({ pong: true }),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
test("without anonymousAccess wired, the jwtGuard still 401s — anonymous does NOT bypass /api/*", async () => {
|
|
143
|
+
const stack = await setupTestStack({ features: [probeFeature], extraRoutes: [apiPingRoute] });
|
|
144
|
+
try {
|
|
145
|
+
const res = await stack.app.request("/api/public-ping");
|
|
146
|
+
expect(res.status).toBe(401);
|
|
147
|
+
} finally {
|
|
148
|
+
await stack.cleanup();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("with anonymousAccess wired, the request clears the guard exactly like any other anonymous /api/* call", async () => {
|
|
153
|
+
const stack = await setupTestStack({
|
|
154
|
+
features: [probeFeature],
|
|
155
|
+
extraRoutes: [apiPingRoute],
|
|
156
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
const res = await stack.app.request("/api/public-ping");
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
} finally {
|
|
162
|
+
await stack.cleanup();
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("extraRoutes: entry:anonymous deps.write (kumiko-framework#3050 anonymous write)", () => {
|
|
168
|
+
const writeRoute: AnonymousExtraRoute = {
|
|
169
|
+
method: "POST",
|
|
170
|
+
path: "/api/anon-write-probe",
|
|
171
|
+
entry: "anonymous",
|
|
172
|
+
handler: async (c, deps) => {
|
|
173
|
+
const result = await deps.write("anonymous-write-probe:write:anon-write", {
|
|
174
|
+
note: "from-anon",
|
|
175
|
+
});
|
|
176
|
+
return c.json(result);
|
|
177
|
+
},
|
|
178
|
+
};
|
|
179
|
+
const gatedWriteRoute: AnonymousExtraRoute = {
|
|
180
|
+
method: "POST",
|
|
181
|
+
path: "/api/anon-gated-write-probe",
|
|
182
|
+
entry: "anonymous",
|
|
183
|
+
handler: async (c, deps) => {
|
|
184
|
+
const result = await deps.write("anonymous-write-probe:write:anon-gated-write", {});
|
|
185
|
+
return c.json(result);
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
// Catches the thrown error itself and surfaces its message — proves what
|
|
189
|
+
// deps.write actually throws, not just that *something* threw.
|
|
190
|
+
const outsideApiWriteRoute: AnonymousExtraRoute = {
|
|
191
|
+
method: "POST",
|
|
192
|
+
path: "/public/anon-write-probe",
|
|
193
|
+
entry: "anonymous",
|
|
194
|
+
handler: async (c, deps) => {
|
|
195
|
+
try {
|
|
196
|
+
const result = await deps.write("anonymous-write-probe:write:anon-write", {
|
|
197
|
+
note: "should-not-persist",
|
|
198
|
+
});
|
|
199
|
+
return c.json(result);
|
|
200
|
+
} catch (e) {
|
|
201
|
+
return c.json({ error: { message: e instanceof Error ? e.message : String(e) } }, 500);
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const OTHER_TENANT_ID = "00000000-0000-4000-8000-000000000099" as TenantId;
|
|
207
|
+
let stack: TestStack;
|
|
208
|
+
|
|
209
|
+
beforeAll(async () => {
|
|
210
|
+
anonymousWriteStore.clear();
|
|
211
|
+
stack = await setupTestStack({
|
|
212
|
+
features: [anonymousWriteFeature],
|
|
213
|
+
extraRoutes: [writeRoute, gatedWriteRoute, outsideApiWriteRoute],
|
|
214
|
+
// No defaultTenantId: X-Tenant picks the tenant per request, so the cross-tenant test below is real.
|
|
215
|
+
anonymousAccess: {
|
|
216
|
+
tenantExists: async (id) => id === TENANT_ID || id === OTHER_TENANT_ID,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
afterAll(() => stack.cleanup());
|
|
222
|
+
|
|
223
|
+
test("under /api/ with anonymousAccess wired, deps.write runs against a handler allowing roles:['anonymous'] and actually persists", async () => {
|
|
224
|
+
const res = await stack.app.request("/api/anon-write-probe", {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { "X-Tenant": TENANT_ID },
|
|
227
|
+
});
|
|
228
|
+
expect(res.status).toBe(200);
|
|
229
|
+
const body = (await res.json()) as { isSuccess: boolean; data: { tenantSeen: string } };
|
|
230
|
+
expect(body.isSuccess).toBe(true);
|
|
231
|
+
expect(body.data.tenantSeen).toBe(TENANT_ID);
|
|
232
|
+
|
|
233
|
+
const notesRes = await stack.app.request("/api/query", {
|
|
234
|
+
method: "POST",
|
|
235
|
+
headers: { "content-type": "application/json", "X-Tenant": TENANT_ID },
|
|
236
|
+
body: JSON.stringify({ type: "anonymous-write-probe:query:anon-notes", payload: {} }),
|
|
237
|
+
});
|
|
238
|
+
expect(notesRes.status).toBe(200);
|
|
239
|
+
const notesBody = (await notesRes.json()) as { data: { notes: readonly string[] } };
|
|
240
|
+
expect(notesBody.data.notes).toContain("from-anon");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("deps.write is access-checked as the anonymous session — a role-gated handler is denied, nothing written", async () => {
|
|
244
|
+
const before = anonymousWriteStore.get(TENANT_ID)?.length ?? 0;
|
|
245
|
+
const res = await stack.app.request("/api/anon-gated-write-probe", {
|
|
246
|
+
method: "POST",
|
|
247
|
+
headers: { "X-Tenant": TENANT_ID },
|
|
248
|
+
});
|
|
249
|
+
expect(res.status).toBe(200);
|
|
250
|
+
const body = (await res.json()) as { isSuccess: boolean; error?: { code: string } };
|
|
251
|
+
expect(body.isSuccess).toBe(false);
|
|
252
|
+
expect(body.error?.code).toBe("access_denied");
|
|
253
|
+
expect(anonymousWriteStore.get(TENANT_ID)?.length ?? 0).toBe(before);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("deps.write parity: a Bearer token whose role clears the gate succeeds — same as calling /api/write directly (see the no-token case above, which 403s)", async () => {
|
|
257
|
+
const token = await stack.jwt.sign(TestUsers.admin);
|
|
258
|
+
const res = await stack.app.request("/api/anon-gated-write-probe", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
261
|
+
});
|
|
262
|
+
expect(res.status).toBe(200);
|
|
263
|
+
const body = (await res.json()) as { isSuccess: boolean };
|
|
264
|
+
expect(body.isSuccess).toBe(true);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("deps.write lands in the request-resolved tenant, not a different one", async () => {
|
|
268
|
+
// The store is module-level; earlier tests already wrote into TENANT_ID.
|
|
269
|
+
anonymousWriteStore.clear();
|
|
270
|
+
const res = await stack.app.request("/api/anon-write-probe", {
|
|
271
|
+
method: "POST",
|
|
272
|
+
headers: { "X-Tenant": OTHER_TENANT_ID },
|
|
273
|
+
});
|
|
274
|
+
expect(res.status).toBe(200);
|
|
275
|
+
const body = (await res.json()) as { data: { tenantSeen: string } };
|
|
276
|
+
expect(body.data.tenantSeen).toBe(OTHER_TENANT_ID);
|
|
277
|
+
expect(anonymousWriteStore.get(OTHER_TENANT_ID)).toContain("from-anon");
|
|
278
|
+
expect(anonymousWriteStore.has(TENANT_ID)).toBe(false);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("outside /api/, deps.write throws with a message naming the /api/ + anonymousAccess requirement, nothing written", async () => {
|
|
282
|
+
const before = anonymousWriteStore.get(TENANT_ID)?.length ?? 0;
|
|
283
|
+
const res = await stack.app.request("/public/anon-write-probe", { method: "POST" });
|
|
284
|
+
expect(res.status).toBe(500);
|
|
285
|
+
const body = (await res.json()) as { error: { message: string } };
|
|
286
|
+
expect(body.error.message).toMatch(/mounted\s+under\s+"\/api\/"\s+with\s+anonymousAccess/);
|
|
287
|
+
expect(anonymousWriteStore.get(TENANT_ID)?.length ?? 0).toBe(before);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
describe("extraRoutes: entry:user", () => {
|
|
292
|
+
const selfWriteRoute: UserExtraRoute = {
|
|
293
|
+
method: "POST",
|
|
294
|
+
path: "/api/user-probe",
|
|
295
|
+
entry: "user",
|
|
296
|
+
handler: async (c, deps) => {
|
|
297
|
+
const result = await deps.write("extra-route-probe:write:self-write", { note: "hi" });
|
|
298
|
+
return c.json({ userId: deps.user.id, result });
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
const adminOnlyRoute: UserExtraRoute = {
|
|
302
|
+
method: "POST",
|
|
303
|
+
path: "/api/user-admin-probe",
|
|
304
|
+
entry: "user",
|
|
305
|
+
handler: async (c, deps) => {
|
|
306
|
+
const result = await deps.write("extra-route-probe:write:admin-only-write", {});
|
|
307
|
+
return c.json(result);
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
let stack: TestStack;
|
|
312
|
+
|
|
313
|
+
beforeAll(async () => {
|
|
314
|
+
stack = await setupTestStack({
|
|
315
|
+
features: [probeFeature],
|
|
316
|
+
extraRoutes: [selfWriteRoute, adminOnlyRoute],
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
afterAll(() => stack.cleanup());
|
|
321
|
+
|
|
322
|
+
test("without a session → 401 (jwtGuard rejects before the route ever runs)", async () => {
|
|
323
|
+
const res = await stack.app.request("/api/user-probe", { method: "POST" });
|
|
324
|
+
expect(res.status).toBe(401);
|
|
325
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
326
|
+
expect(body.error.code).toBe("missing_token");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("with a real session → 200, deps.user.id matches the JWT subject", async () => {
|
|
330
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
331
|
+
const res = await stack.app.request("/api/user-probe", {
|
|
332
|
+
method: "POST",
|
|
333
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
334
|
+
});
|
|
335
|
+
expect(res.status).toBe(200);
|
|
336
|
+
const body = (await res.json()) as { userId: string; result: { isSuccess: boolean } };
|
|
337
|
+
expect(body.userId).toBe(TestUsers.user.id);
|
|
338
|
+
expect(body.result.isSuccess).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("deps.write runs access-checked as the calling user — 'User' role denied on an Admin-only handler", async () => {
|
|
342
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
343
|
+
const res = await stack.app.request("/api/user-admin-probe", {
|
|
344
|
+
method: "POST",
|
|
345
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
346
|
+
});
|
|
347
|
+
expect(res.status).toBe(200);
|
|
348
|
+
const body = (await res.json()) as { isSuccess: boolean; error?: { code: string } };
|
|
349
|
+
expect(body.isSuccess).toBe(false);
|
|
350
|
+
expect(body.error?.code).toBe("access_denied");
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("deps.write succeeds for a user whose role clears the handler's access check", async () => {
|
|
354
|
+
const token = await stack.jwt.sign(TestUsers.admin);
|
|
355
|
+
const res = await stack.app.request("/api/user-admin-probe", {
|
|
356
|
+
method: "POST",
|
|
357
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
358
|
+
});
|
|
359
|
+
expect(res.status).toBe(200);
|
|
360
|
+
const body = (await res.json()) as { isSuccess: boolean };
|
|
361
|
+
expect(body.isSuccess).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe("extraRoutes: entry:user under an anonymousAccess-wired server", () => {
|
|
366
|
+
const selfWriteRoute: UserExtraRoute = {
|
|
367
|
+
method: "POST",
|
|
368
|
+
path: "/api/user-probe",
|
|
369
|
+
entry: "user",
|
|
370
|
+
handler: async (c, deps) => c.json({ userId: deps.user.id }),
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
test("no JWT: jwtGuard synthesises an anonymous user, buildExtraRouteHonoHandler's own ANONYMOUS_ROLE check still 401s", async () => {
|
|
374
|
+
const stack = await setupTestStack({
|
|
375
|
+
features: [probeFeature],
|
|
376
|
+
extraRoutes: [selfWriteRoute],
|
|
377
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
378
|
+
});
|
|
379
|
+
try {
|
|
380
|
+
const res = await stack.app.request("/api/user-probe", { method: "POST" });
|
|
381
|
+
expect(res.status).toBe(401);
|
|
382
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
383
|
+
expect(body.error.code).toBe("unauthenticated");
|
|
384
|
+
} finally {
|
|
385
|
+
await stack.cleanup();
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("extraRoutes: entry:user route mounted outside /api throws at boot", () => {
|
|
391
|
+
const registry = createRegistry([probeFeature]);
|
|
392
|
+
expect(() =>
|
|
393
|
+
buildServer({
|
|
394
|
+
registry,
|
|
395
|
+
context: {},
|
|
396
|
+
jwtSecret: JWT_SECRET,
|
|
397
|
+
extraRoutes: [
|
|
398
|
+
{
|
|
399
|
+
method: "GET",
|
|
400
|
+
path: "/user-outside-api",
|
|
401
|
+
entry: "user",
|
|
402
|
+
handler: async (c) => c.json({}),
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
}),
|
|
406
|
+
).toThrow(/must be\s+mounted under "\/api\/"/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("extraRoutes: entry:signature wildcard under /api throws at boot", () => {
|
|
410
|
+
const registry = createRegistry([probeFeature]);
|
|
411
|
+
expect(() =>
|
|
412
|
+
buildServer({
|
|
413
|
+
registry,
|
|
414
|
+
context: {},
|
|
415
|
+
jwtSecret: JWT_SECRET,
|
|
416
|
+
extraRoutes: [
|
|
417
|
+
signatureRoute({
|
|
418
|
+
method: "POST",
|
|
419
|
+
path: "/api/*",
|
|
420
|
+
entry: "signature",
|
|
421
|
+
verify: async () => true,
|
|
422
|
+
handler: async (c) => c.json({}),
|
|
423
|
+
}),
|
|
424
|
+
],
|
|
425
|
+
}),
|
|
426
|
+
).toThrow(/must not\s+use a wildcard under "\/api\/"/);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("extraRoutes: an unknown entry value throws at boot", () => {
|
|
430
|
+
const registry = createRegistry([probeFeature]);
|
|
431
|
+
// A JS caller without the ExtraRouteDefinition type can construct this at
|
|
432
|
+
// runtime — buildServer must reject it at boot, not at first request.
|
|
433
|
+
const badRoute = {
|
|
434
|
+
method: "GET",
|
|
435
|
+
path: "/whatever",
|
|
436
|
+
entry: "admin",
|
|
437
|
+
handler: async () => new Response(),
|
|
438
|
+
} as unknown as ExtraRouteDefinition; // @cast-boundary simulates an untyped JS caller passing an unknown entry
|
|
439
|
+
expect(() =>
|
|
440
|
+
buildServer({
|
|
441
|
+
registry,
|
|
442
|
+
context: {},
|
|
443
|
+
jwtSecret: JWT_SECRET,
|
|
444
|
+
extraRoutes: [badRoute],
|
|
445
|
+
}),
|
|
446
|
+
).toThrow(/unknown entry/);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
describe("extraRoutes: entry:signature", () => {
|
|
450
|
+
const HMAC_KEY = "shared-hmac-key";
|
|
451
|
+
|
|
452
|
+
function signHmac(rawBody: string): string {
|
|
453
|
+
return createHmac("sha256", HMAC_KEY).update(rawBody).digest("hex");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const webhookRoute: SignatureExtraRoute<{ note: string }> = {
|
|
457
|
+
method: "POST",
|
|
458
|
+
path: "/webhooks/probe",
|
|
459
|
+
entry: "signature",
|
|
460
|
+
verify: async (req) => {
|
|
461
|
+
if (req.headers["x-force-404"] === "1") {
|
|
462
|
+
throw new ExtraRouteRejection(404, { error: "unknown-provider" });
|
|
463
|
+
}
|
|
464
|
+
if (req.headers["x-hmac"] !== signHmac(req.rawBody)) {
|
|
465
|
+
throw new Error("signature mismatch");
|
|
466
|
+
}
|
|
467
|
+
return { note: (JSON.parse(req.rawBody) as { note: string }).note };
|
|
468
|
+
},
|
|
469
|
+
handler: async (c, verified, deps) => {
|
|
470
|
+
const result = await deps.dispatchSystemWrite({
|
|
471
|
+
handlerQn: "extra-route-probe:write:hmac-write",
|
|
472
|
+
payload: { note: verified.note },
|
|
473
|
+
tenantId: TENANT_ID,
|
|
474
|
+
});
|
|
475
|
+
return c.json(result);
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const webhookParamRoute: SignatureExtraRoute<{ provider: string }> = {
|
|
480
|
+
method: "POST",
|
|
481
|
+
path: "/api/webhooks/:provider",
|
|
482
|
+
entry: "signature",
|
|
483
|
+
verify: async (req) => {
|
|
484
|
+
if (req.headers["x-hmac"] !== signHmac(req.rawBody)) throw new Error("signature mismatch");
|
|
485
|
+
return { provider: req.params["provider"] ?? "" };
|
|
486
|
+
},
|
|
487
|
+
handler: async (c, verified) => c.json({ provider: verified.provider }),
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
let stack: TestStack;
|
|
491
|
+
|
|
492
|
+
beforeAll(async () => {
|
|
493
|
+
stack = await setupTestStack({
|
|
494
|
+
features: [probeFeature],
|
|
495
|
+
extraRoutes: [signatureRoute(webhookRoute), signatureRoute(webhookParamRoute)],
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
afterAll(() => stack.cleanup());
|
|
500
|
+
|
|
501
|
+
test("wrong signature → 401 extra_route_signature_invalid", async () => {
|
|
502
|
+
const res = await stack.app.request("/webhooks/probe", {
|
|
503
|
+
method: "POST",
|
|
504
|
+
headers: { "x-hmac": "wrong", "content-type": "application/json" },
|
|
505
|
+
body: JSON.stringify({ note: "x" }),
|
|
506
|
+
});
|
|
507
|
+
expect(res.status).toBe(401);
|
|
508
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
509
|
+
expect(body.error.code).toBe("extra_route_signature_invalid");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("right signature → 200, dispatchSystemWrite's SystemAdmin write is visible in the response", async () => {
|
|
513
|
+
const rawBody = JSON.stringify({ note: "from-webhook" });
|
|
514
|
+
const res = await stack.app.request("/webhooks/probe", {
|
|
515
|
+
method: "POST",
|
|
516
|
+
headers: { "x-hmac": signHmac(rawBody), "content-type": "application/json" },
|
|
517
|
+
body: rawBody,
|
|
518
|
+
});
|
|
519
|
+
expect(res.status).toBe(200);
|
|
520
|
+
const body = (await res.json()) as {
|
|
521
|
+
isSuccess: boolean;
|
|
522
|
+
data?: { tenantSeen: string; note: string };
|
|
523
|
+
};
|
|
524
|
+
expect(body.isSuccess).toBe(true);
|
|
525
|
+
expect(body.data?.tenantSeen).toBe(TENANT_ID);
|
|
526
|
+
expect(body.data?.note).toBe("from-webhook");
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
test("verify() throwing ExtraRouteRejection(404, body) surfaces exactly that body", async () => {
|
|
530
|
+
const res = await stack.app.request("/webhooks/probe", {
|
|
531
|
+
method: "POST",
|
|
532
|
+
headers: { "x-force-404": "1", "content-type": "application/json" },
|
|
533
|
+
body: JSON.stringify({ note: "x" }),
|
|
534
|
+
});
|
|
535
|
+
expect(res.status).toBe(404);
|
|
536
|
+
expect(await res.json()).toEqual({ error: "unknown-provider" });
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("mounted under /api/:provider — no session needed, honoPathToRegex matches the :param, rawBody arrives intact through /api/*", async () => {
|
|
540
|
+
const rawBody = JSON.stringify({ event: "payment.succeeded" });
|
|
541
|
+
const res = await stack.app.request("/api/webhooks/stripe", {
|
|
542
|
+
method: "POST",
|
|
543
|
+
headers: { "x-hmac": signHmac(rawBody), "content-type": "application/json" },
|
|
544
|
+
body: rawBody,
|
|
545
|
+
});
|
|
546
|
+
expect(res.status).toBe(200);
|
|
547
|
+
expect(await res.json()).toEqual({ provider: "stripe" });
|
|
548
|
+
});
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
// Type-level contract for ExtraRouteDefinition (kumiko-framework#3050): the
|
|
552
|
+
// bodies below are never invoked — tsc checks them, bun:test does not run
|
|
553
|
+
// them. Each `@ts-expect-error` turns into an "unused directive" compile
|
|
554
|
+
// error if the constraint ever stops firing.
|
|
555
|
+
function _requiresEntry(): ExtraRouteDefinition {
|
|
556
|
+
// @ts-expect-error — entry is required; a def without it must not satisfy ExtraRouteDefinition.
|
|
557
|
+
return { method: "GET", path: "/probe", handler: () => new Response() };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function _anonymousDepsHaveNoDispatchSystemWrite(): AnonymousExtraRoute {
|
|
561
|
+
return {
|
|
562
|
+
method: "GET",
|
|
563
|
+
path: "/probe",
|
|
564
|
+
entry: "anonymous",
|
|
565
|
+
handler: (c, deps) => {
|
|
566
|
+
// @ts-expect-error — anonymous deps expose systemQuery only, no dispatchSystemWrite.
|
|
567
|
+
void deps.dispatchSystemWrite;
|
|
568
|
+
return c.json({});
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function _userDepsHaveNoDispatchSystemWrite(): UserExtraRoute {
|
|
574
|
+
return {
|
|
575
|
+
method: "GET",
|
|
576
|
+
path: "/api/probe",
|
|
577
|
+
entry: "user",
|
|
578
|
+
handler: (c, deps) => {
|
|
579
|
+
// @ts-expect-error — user deps expose query/write only, no dispatchSystemWrite.
|
|
580
|
+
void deps.dispatchSystemWrite;
|
|
581
|
+
return c.json({});
|
|
582
|
+
},
|
|
583
|
+
};
|
|
584
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// ExtraRoute — declarative replacement for the old `extraRoutes: (app, deps)
|
|
2
|
+
// => void` closure (kumiko-framework#3050). Every entry declares its access
|
|
3
|
+
// tier (`entry`) up front; buildServer wires the matching guard + deps
|
|
4
|
+
// instead of handing every route a raw db/redis escape hatch.
|
|
5
|
+
|
|
6
|
+
import type { Context, Hono } from "hono";
|
|
7
|
+
import type {
|
|
8
|
+
HttpRouteMethod,
|
|
9
|
+
Registry,
|
|
10
|
+
SessionUser,
|
|
11
|
+
TenantId,
|
|
12
|
+
WriteResult,
|
|
13
|
+
} from "../engine/types";
|
|
14
|
+
import type { SecretsContext } from "../secrets";
|
|
15
|
+
|
|
16
|
+
export const ExtraRouteEntries = {
|
|
17
|
+
anonymous: "anonymous",
|
|
18
|
+
user: "user",
|
|
19
|
+
signature: "signature",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export type ExtraRouteEntry = (typeof ExtraRouteEntries)[keyof typeof ExtraRouteEntries];
|
|
23
|
+
|
|
24
|
+
export type AnonymousExtraRouteDeps = {
|
|
25
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono's generic-Param ist im Framework-Boundary unsichtbar
|
|
26
|
+
readonly app: Hono<any, any>;
|
|
27
|
+
readonly registry: Registry;
|
|
28
|
+
readonly systemQuery: (type: string, payload: unknown, tenantId: TenantId) => Promise<unknown>;
|
|
29
|
+
/** Runs as the session the /api chain resolved for this request —
|
|
30
|
+
* anonymous when no token is sent, the authenticated user otherwise.
|
|
31
|
+
* Never more than that caller could already do via /api/write: same
|
|
32
|
+
* user, same request-resolved tenant (never overridable), only under
|
|
33
|
+
* "/api/" (the only path that populates a session user). */
|
|
34
|
+
readonly write: (type: string, payload: unknown) => Promise<WriteResult>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AnonymousExtraRoute = {
|
|
38
|
+
readonly method: HttpRouteMethod;
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly entry: "anonymous";
|
|
41
|
+
readonly handler: (
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
43
|
+
c: Context<any, any>,
|
|
44
|
+
deps: AnonymousExtraRouteDeps,
|
|
45
|
+
) => Response | Promise<Response>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export type UserExtraRouteDeps = {
|
|
49
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono's generic-Param ist im Framework-Boundary unsichtbar
|
|
50
|
+
readonly app: Hono<any, any>;
|
|
51
|
+
readonly registry: Registry;
|
|
52
|
+
readonly user: SessionUser;
|
|
53
|
+
readonly query: (type: string, payload: unknown) => Promise<unknown>;
|
|
54
|
+
readonly write: (type: string, payload: unknown) => Promise<WriteResult>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type UserExtraRoute = {
|
|
58
|
+
readonly method: HttpRouteMethod;
|
|
59
|
+
readonly path: string;
|
|
60
|
+
readonly entry: "user";
|
|
61
|
+
readonly handler: (
|
|
62
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
63
|
+
c: Context<any, any>,
|
|
64
|
+
deps: UserExtraRouteDeps,
|
|
65
|
+
) => Response | Promise<Response>;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type SignatureExtraRouteVerifyRequest = {
|
|
69
|
+
readonly rawBody: string;
|
|
70
|
+
/** Lowercase header names — Hono/undici normalize incoming headers to
|
|
71
|
+
* lowercase, verify() must not have to re-normalize per provider. */
|
|
72
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
73
|
+
readonly params: Readonly<Record<string, string>>;
|
|
74
|
+
readonly query: Readonly<Record<string, string>>;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type SignatureExtraRouteVerifyDeps = {
|
|
78
|
+
readonly registry: Registry;
|
|
79
|
+
readonly secrets?: SecretsContext;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type SystemDispatchArgs = {
|
|
83
|
+
readonly handlerQn: string;
|
|
84
|
+
readonly payload: unknown;
|
|
85
|
+
readonly tenantId: TenantId;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type SignatureExtraRouteDeps = {
|
|
89
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono's generic-Param ist im Framework-Boundary unsichtbar
|
|
90
|
+
readonly app: Hono<any, any>;
|
|
91
|
+
readonly registry: Registry;
|
|
92
|
+
readonly secrets?: SecretsContext;
|
|
93
|
+
readonly systemQuery: (type: string, payload: unknown, tenantId: TenantId) => Promise<unknown>;
|
|
94
|
+
/** Privilege scope: SystemAdmin of the target tenant, WITHOUT the route's
|
|
95
|
+
* access check — only reachable because verify() has already proven the
|
|
96
|
+
* caller's authenticity (signature, HMAC state, etc.). */
|
|
97
|
+
readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
|
|
98
|
+
readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export type SignatureExtraRoute<TVerified> = {
|
|
102
|
+
readonly method: HttpRouteMethod;
|
|
103
|
+
readonly path: string;
|
|
104
|
+
readonly entry: "signature";
|
|
105
|
+
readonly verify: (
|
|
106
|
+
request: SignatureExtraRouteVerifyRequest,
|
|
107
|
+
deps: SignatureExtraRouteVerifyDeps,
|
|
108
|
+
) => Promise<TVerified>;
|
|
109
|
+
readonly handler: (
|
|
110
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
111
|
+
c: Context<any, any>,
|
|
112
|
+
verified: TVerified,
|
|
113
|
+
deps: SignatureExtraRouteDeps,
|
|
114
|
+
) => Response | Promise<Response>;
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export type ExtraRouteDefinition =
|
|
118
|
+
| AnonymousExtraRoute
|
|
119
|
+
| UserExtraRoute
|
|
120
|
+
| SignatureExtraRoute<unknown>;
|
|
121
|
+
|
|
122
|
+
/** Narrows a `SignatureExtraRoute<T>` into the storable `ExtraRouteDefinition`
|
|
123
|
+
* union. The single point where the verify/handler generic `T` is erased —
|
|
124
|
+
* callers keep full type-safety between their own verify() and handler(). */
|
|
125
|
+
export function signatureRoute<T>(def: SignatureExtraRoute<T>): ExtraRouteDefinition {
|
|
126
|
+
// @cast-boundary generic erasure at the public ExtraRouteDefinition boundary;
|
|
127
|
+
// verify() and handler() above stay paired through T at the call site.
|
|
128
|
+
return def as unknown as SignatureExtraRoute<unknown>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export type ExtraRouteRejectionStatus = 400 | 401 | 403 | 404 | 500;
|
|
132
|
+
|
|
133
|
+
/** Thrown by `verify()` to reject a signature route with a specific status +
|
|
134
|
+
* JSON body. Any other throw from `verify()` is mapped to 401
|
|
135
|
+
* `extra_route_signature_invalid` by the buildServer wrapper. */
|
|
136
|
+
export class ExtraRouteRejection extends Error {
|
|
137
|
+
readonly status: ExtraRouteRejectionStatus;
|
|
138
|
+
readonly body: unknown;
|
|
139
|
+
|
|
140
|
+
constructor(status: ExtraRouteRejectionStatus, body: unknown, message?: string) {
|
|
141
|
+
super(message ?? `extra route rejected with status ${status}`);
|
|
142
|
+
this.name = "ExtraRouteRejection";
|
|
143
|
+
this.status = status;
|
|
144
|
+
this.body = body;
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/api/index.ts
CHANGED
|
@@ -28,6 +28,21 @@ export {
|
|
|
28
28
|
createInMemoryLoginRateLimiter,
|
|
29
29
|
createRedisLoginRateLimiter,
|
|
30
30
|
} from "./auth-routes";
|
|
31
|
+
export type {
|
|
32
|
+
AnonymousExtraRoute,
|
|
33
|
+
AnonymousExtraRouteDeps,
|
|
34
|
+
ExtraRouteDefinition,
|
|
35
|
+
ExtraRouteEntry,
|
|
36
|
+
ExtraRouteRejectionStatus,
|
|
37
|
+
SignatureExtraRoute,
|
|
38
|
+
SignatureExtraRouteDeps,
|
|
39
|
+
SignatureExtraRouteVerifyDeps,
|
|
40
|
+
SignatureExtraRouteVerifyRequest,
|
|
41
|
+
SystemDispatchArgs,
|
|
42
|
+
UserExtraRoute,
|
|
43
|
+
UserExtraRouteDeps,
|
|
44
|
+
} from "./extra-route";
|
|
45
|
+
export { ExtraRouteEntries, ExtraRouteRejection, signatureRoute } from "./extra-route";
|
|
31
46
|
export type { CachedResponseInit, CachePolicy } from "./http-cache";
|
|
32
47
|
export {
|
|
33
48
|
cacheControlHeader,
|
|
@@ -50,7 +65,7 @@ export {
|
|
|
50
65
|
} from "./request-id-middleware";
|
|
51
66
|
export { createApiRoutes } from "./routes";
|
|
52
67
|
export type { KumikoServer, ServerOptions } from "./server";
|
|
53
|
-
export { buildServer } from "./server";
|
|
68
|
+
export { buildServer, makeDispatchSystemQuery, makeDispatchSystemWrite } from "./server";
|
|
54
69
|
export type { SseBroker, SseClient, SseEvent } from "./sse-broker";
|
|
55
70
|
export { createSseBroker } from "./sse-broker";
|
|
56
71
|
export { createSseRoute, SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
|
package/src/api/server.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
|
+
import { ROLES } from "../auth/roles";
|
|
2
3
|
import type { DbConnection, PgClient } from "../db/connection";
|
|
3
4
|
import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
4
5
|
import { EXT_FILE_PROVIDER, EXT_PRINCIPAL_STATUS } from "../engine/extension-names";
|
|
5
6
|
import { runsInLane } from "../engine/run-in";
|
|
6
|
-
import { createAnonymousUser } from "../engine/system-user";
|
|
7
|
-
import {
|
|
7
|
+
import { ANONYMOUS_ROLE, createAnonymousUser, createSystemUser } from "../engine/system-user";
|
|
8
|
+
import {
|
|
9
|
+
type AppContext,
|
|
10
|
+
type HttpRouteMethod,
|
|
11
|
+
isFileField,
|
|
12
|
+
type Registry,
|
|
13
|
+
type RunIn,
|
|
14
|
+
type TenantId,
|
|
15
|
+
type WriteResult,
|
|
16
|
+
} from "../engine/types";
|
|
8
17
|
import { createFileContext } from "../files/file-handle";
|
|
9
18
|
import type { FileRoutesOptions } from "../files/file-routes";
|
|
10
19
|
import { createFileRoutes } from "../files/file-routes";
|
|
@@ -52,6 +61,13 @@ import {
|
|
|
52
61
|
} from "./auth-middleware";
|
|
53
62
|
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
54
63
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
64
|
+
import {
|
|
65
|
+
type ExtraRouteDefinition,
|
|
66
|
+
ExtraRouteEntries,
|
|
67
|
+
type ExtraRouteEntry,
|
|
68
|
+
ExtraRouteRejection,
|
|
69
|
+
type SystemDispatchArgs,
|
|
70
|
+
} from "./extra-route";
|
|
55
71
|
import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
|
|
56
72
|
import { observabilityMiddleware } from "./observability-middleware";
|
|
57
73
|
import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
|
|
@@ -219,6 +235,13 @@ export type ServerOptions = {
|
|
|
219
235
|
// (defaultTenantId only); run{Prod,Dev}App merge auth-foundation tenant
|
|
220
236
|
// providers into AnonymousAccessResolved before calling buildServer.
|
|
221
237
|
anonymousAccess?: AnonymousAccessResolved;
|
|
238
|
+
// Declarative HTTP routes outside the /api/write|query|batch pipeline that
|
|
239
|
+
// still need the framework's dispatcher (webhooks, OAuth callbacks, admin
|
|
240
|
+
// escape-hatches). Each entry declares its access tier (`entry`) up
|
|
241
|
+
// front — buildServer wires the matching guard + deps, no handler gets a
|
|
242
|
+
// raw db/redis. Mounted right after the r.httpRoute loop, before
|
|
243
|
+
// registerVersionRoute (kumiko-framework#3050).
|
|
244
|
+
extraRoutes?: readonly ExtraRouteDefinition[];
|
|
222
245
|
};
|
|
223
246
|
|
|
224
247
|
export type KumikoServer = {
|
|
@@ -606,6 +629,13 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
606
629
|
|
|
607
630
|
const app = new Hono();
|
|
608
631
|
|
|
632
|
+
// Only entry:"signature" bypasses jwtGuard (verify() authenticates
|
|
633
|
+
// itself); entry:"anonymous" still needs the anonymousAccess fallthrough
|
|
634
|
+
// (tenant-by-host), entry:"user" needs c.get("user") populated.
|
|
635
|
+
const extraRoutePublicMatchers = compileExtraRoutePublicMatchers(options.extraRoutes);
|
|
636
|
+
const isExtraRoutePublicPath = (c: import("hono").Context): boolean =>
|
|
637
|
+
extraRoutePublicMatchers.some((m) => m.method === c.req.method && m.pattern.test(c.req.path));
|
|
638
|
+
|
|
609
639
|
const sensitiveConfig = mergeSensitiveConfig(
|
|
610
640
|
options.observabilityOptions?.sensitiveFilter ?? DEFAULT_SENSITIVE_CONFIG,
|
|
611
641
|
);
|
|
@@ -689,7 +719,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
689
719
|
...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
|
|
690
720
|
});
|
|
691
721
|
app.use("/api/*", async (c, next) => {
|
|
692
|
-
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
722
|
+
if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
|
|
693
723
|
return jwtGuard(c, next);
|
|
694
724
|
});
|
|
695
725
|
|
|
@@ -701,7 +731,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
701
731
|
const patRateLimiter = options.auth?.patRateLimiter;
|
|
702
732
|
if (patRateLimiter) {
|
|
703
733
|
app.use("/api/*", async (c, next) => {
|
|
704
|
-
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
734
|
+
if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
|
|
705
735
|
const pat = getUser(c)?.pat;
|
|
706
736
|
if (pat && !(await patRateLimiter.check(pat.tokenId))) {
|
|
707
737
|
return c.json(
|
|
@@ -733,7 +763,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
733
763
|
if (allowedOrigins && allowedOrigins.length > 0) {
|
|
734
764
|
const originGuard = originMiddleware(allowedOrigins);
|
|
735
765
|
app.use("/api/*", async (c, next) => {
|
|
736
|
-
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
766
|
+
if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
|
|
737
767
|
return originGuard(c, next);
|
|
738
768
|
});
|
|
739
769
|
}
|
|
@@ -747,7 +777,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
747
777
|
// are covered uniformly.
|
|
748
778
|
const csrfGuard = csrfMiddleware();
|
|
749
779
|
app.use("/api/*", async (c, next) => {
|
|
750
|
-
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
780
|
+
if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
|
|
751
781
|
return csrfGuard(c, next);
|
|
752
782
|
});
|
|
753
783
|
|
|
@@ -815,55 +845,74 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
815
845
|
const honoHandler = async (c: import("hono").Context): Promise<Response> =>
|
|
816
846
|
route.handler(c, {
|
|
817
847
|
app,
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
//
|
|
831
|
-
// httpRoute handlers run OUTSIDE /api/* — requestIdMiddleware
|
|
832
|
-
// (which wraps requestContext.run with ip/requestId/
|
|
833
|
-
// correlationId) never sees this request. Without this wrap,
|
|
834
|
-
// `rateLimit: {per: "ip", ...}` on a handler invoked via
|
|
835
|
-
// systemQuery is silent dead-code: enforceRateLimit reads
|
|
836
|
-
// requestContext.get()?.ip, which is undefined here, so
|
|
837
|
-
// buildBucketKey always returns {kind: "skip"}.
|
|
838
|
-
requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
|
|
839
|
-
dispatcher.query(type, payload, createAnonymousUser(tenantId)),
|
|
840
|
-
),
|
|
848
|
+
// createAnonymousUser, NOT createSystemUser: httpRoute handlers
|
|
849
|
+
// using systemQuery are, by construction, `anonymous: true`
|
|
850
|
+
// public routes — the synthesized user must clear the SAME
|
|
851
|
+
// access gate a real anonymous visitor would, no more. The
|
|
852
|
+
// system role would ALSO satisfy that gate here, but it can
|
|
853
|
+
// read fields gated to "system" that "anonymous" can't
|
|
854
|
+
// (filterReadFields is a plain role-in-map check) — a future
|
|
855
|
+
// systemQuery caller reading a system-gated field would leak
|
|
856
|
+
// it into a public response. The forced tenant already comes
|
|
857
|
+
// from bypassing the HTTP layer entirely; no elevated role
|
|
858
|
+
// is needed or wanted on top of that.
|
|
859
|
+
systemQuery: makeSystemQuery(c, dispatcher),
|
|
841
860
|
});
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
861
|
+
mountHonoRoute(app, route.method, route.path, honoHandler);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// extraRoutes (kumiko-framework#3050) — declarative HTTP-routes with a
|
|
866
|
+
// Pflicht `entry` tier. Mounted after r.httpRoute for the same reason: an
|
|
867
|
+
// extraRoute dispatching through `dispatcher` builds Hono's matcher, so
|
|
868
|
+
// this must run before any seed that also dispatches (runProdApp/
|
|
869
|
+
// createKumikoServer call buildServer before seeding).
|
|
870
|
+
if (options.extraRoutes) {
|
|
871
|
+
// Boot-time validation for the whole list BEFORE mounting anything —
|
|
872
|
+
// an app with one bad route should fail loud at boot, not mount N-1
|
|
873
|
+
// routes and then throw on route N.
|
|
874
|
+
for (const route of options.extraRoutes) {
|
|
875
|
+
if (!isKnownExtraRouteEntry(route.entry)) {
|
|
876
|
+
throw new Error(
|
|
877
|
+
`[kumiko] extraRoutes: unknown entry "${String(route.entry)}" on ` +
|
|
878
|
+
`"${route.method} ${route.path}" — expected "anonymous" | "user" | "signature". ` +
|
|
879
|
+
"A JS caller without the ExtraRouteDefinition type can hit this at boot.",
|
|
880
|
+
);
|
|
881
|
+
}
|
|
882
|
+
if (route.entry === ExtraRouteEntries.user && !route.path.startsWith("/api/")) {
|
|
883
|
+
throw new Error(
|
|
884
|
+
`[kumiko] extraRoutes: entry:"user" route "${route.method} ${route.path}" must be ` +
|
|
885
|
+
"mounted under \"/api/\" — that's the only path prefix that rides the framework's " +
|
|
886
|
+
"jwtGuard chain, which is what populates deps.user.",
|
|
887
|
+
);
|
|
888
|
+
}
|
|
889
|
+
// A signature route under /api/ skips jwtGuard/origin/csrf for every
|
|
890
|
+
// path its pattern matches — a wildcard would switch off auth for
|
|
891
|
+
// unrelated /api/* handlers (e.g. "/api/*" swallows /api/write).
|
|
892
|
+
if (
|
|
893
|
+
route.entry === ExtraRouteEntries.signature &&
|
|
894
|
+
route.path.startsWith("/api/") &&
|
|
895
|
+
route.path.includes("*")
|
|
896
|
+
) {
|
|
897
|
+
throw new Error(
|
|
898
|
+
`[kumiko] extraRoutes: entry:"signature" route "${route.method} ${route.path}" must not ` +
|
|
899
|
+
'use a wildcard under "/api/" — it would bypass the auth chain for every matching path.',
|
|
900
|
+
);
|
|
865
901
|
}
|
|
866
902
|
}
|
|
903
|
+
const dispatchSystemWrite = makeDispatchSystemWrite(dispatcher);
|
|
904
|
+
const dispatchSystemQuery = makeDispatchSystemQuery(dispatcher);
|
|
905
|
+
for (const route of options.extraRoutes) {
|
|
906
|
+
const honoHandler = buildExtraRouteHonoHandler(route, {
|
|
907
|
+
app,
|
|
908
|
+
dispatcher,
|
|
909
|
+
registry: options.registry,
|
|
910
|
+
secrets: contextWithObservability.secrets,
|
|
911
|
+
dispatchSystemWrite,
|
|
912
|
+
dispatchSystemQuery,
|
|
913
|
+
});
|
|
914
|
+
mountHonoRoute(app, route.method, route.path, honoHandler);
|
|
915
|
+
}
|
|
867
916
|
}
|
|
868
917
|
|
|
869
918
|
// /version-Default registriert NACH feature-routes — Hono "first match
|
|
@@ -900,6 +949,222 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
900
949
|
};
|
|
901
950
|
}
|
|
902
951
|
|
|
952
|
+
// Method-switch shared by r.httpRoute and extraRoutes — one place to keep
|
|
953
|
+
// the two mounting paths from drifting on which Hono methods get a
|
|
954
|
+
// convenience-call vs. app.on().
|
|
955
|
+
function mountHonoRoute(
|
|
956
|
+
app: Hono,
|
|
957
|
+
method: HttpRouteMethod,
|
|
958
|
+
path: string,
|
|
959
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
960
|
+
handler: (c: import("hono").Context<any, any>) => Response | Promise<Response>,
|
|
961
|
+
): void {
|
|
962
|
+
switch (method) {
|
|
963
|
+
case "GET":
|
|
964
|
+
app.get(path, handler);
|
|
965
|
+
break;
|
|
966
|
+
case "POST":
|
|
967
|
+
app.post(path, handler);
|
|
968
|
+
break;
|
|
969
|
+
case "PUT":
|
|
970
|
+
app.put(path, handler);
|
|
971
|
+
break;
|
|
972
|
+
case "PATCH":
|
|
973
|
+
app.patch(path, handler);
|
|
974
|
+
break;
|
|
975
|
+
case "DELETE":
|
|
976
|
+
app.delete(path, handler);
|
|
977
|
+
break;
|
|
978
|
+
case "OPTIONS":
|
|
979
|
+
case "HEAD":
|
|
980
|
+
// Hono's on() for the methods without a convenience method.
|
|
981
|
+
app.on(method, path, handler);
|
|
982
|
+
break;
|
|
983
|
+
default:
|
|
984
|
+
assertUnreachable(method, "http method");
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
// Shared systemQuery builder for r.httpRoute and extraRoutes (anonymous +
|
|
989
|
+
// signature entries). requestContext.run must wrap the dispatcher call —
|
|
990
|
+
// both route kinds run outside the requestIdMiddleware chain that normally
|
|
991
|
+
// populates it, and `rateLimit: {per: "ip", ...}` on a handler invoked
|
|
992
|
+
// through systemQuery is otherwise silent dead-code (enforceRateLimit reads
|
|
993
|
+
// requestContext.get()?.ip, undefined without this wrap).
|
|
994
|
+
function makeSystemQuery(
|
|
995
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
996
|
+
c: import("hono").Context<any, any>,
|
|
997
|
+
dispatcher: Dispatcher,
|
|
998
|
+
): (type: string, payload: unknown, tenantId: TenantId) => Promise<unknown> {
|
|
999
|
+
return (type, payload, tenantId) =>
|
|
1000
|
+
requestContext.run(requestContext.get() ?? buildRequestContextData(c), () =>
|
|
1001
|
+
dispatcher.query(type, payload, createAnonymousUser(tenantId)),
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// SystemAdmin write/query builders shared by buildServer's `extraRoutes`
|
|
1006
|
+
// mount and server-runtime's `wire` hook (runProdApp/createKumikoServer,
|
|
1007
|
+
// after buildServer). Privilege-scope: SystemAdmin is the highest
|
|
1008
|
+
// non-tenant-scoped role — reaches ANY SystemAdmin-gated handler on ANY
|
|
1009
|
+
// tenant. Only safe for callers that already proved their own authenticity
|
|
1010
|
+
// (signature verify(), HMAC state, ...), never exposed to a raw request.
|
|
1011
|
+
export function makeDispatchSystemWrite(
|
|
1012
|
+
dispatcher: Dispatcher,
|
|
1013
|
+
): (args: SystemDispatchArgs) => Promise<WriteResult> {
|
|
1014
|
+
return ({ handlerQn, payload, tenantId }) =>
|
|
1015
|
+
dispatcher.write(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
export function makeDispatchSystemQuery(
|
|
1019
|
+
dispatcher: Dispatcher,
|
|
1020
|
+
): (args: SystemDispatchArgs) => Promise<unknown> {
|
|
1021
|
+
return ({ handlerQn, payload, tenantId }) =>
|
|
1022
|
+
dispatcher.query(handlerQn, payload, createSystemUser(tenantId, [ROLES.SystemAdmin]));
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// Same dispatcher.write(...) as entry:"user", but the user is getUser(c) —
|
|
1026
|
+
// see AnonymousExtraRouteDeps.write for the privilege/tenant contract.
|
|
1027
|
+
function makeAnonymousWrite(
|
|
1028
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
1029
|
+
c: import("hono").Context<any, any>,
|
|
1030
|
+
dispatcher: Dispatcher,
|
|
1031
|
+
): (type: string, payload: unknown) => Promise<WriteResult> {
|
|
1032
|
+
return (type, payload) => {
|
|
1033
|
+
const user = getUser(c);
|
|
1034
|
+
if (!user) {
|
|
1035
|
+
throw new Error(
|
|
1036
|
+
'[kumiko] extraRoutes: entry:"anonymous" deps.write requires this route to be mounted ' +
|
|
1037
|
+
'under "/api/" with anonymousAccess wired — no request-resolved session user was found.',
|
|
1038
|
+
);
|
|
1039
|
+
}
|
|
1040
|
+
return dispatcher.write(type, payload, user);
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
function isKnownExtraRouteEntry(entry: unknown): entry is ExtraRouteEntry {
|
|
1045
|
+
return (
|
|
1046
|
+
entry === ExtraRouteEntries.anonymous ||
|
|
1047
|
+
entry === ExtraRouteEntries.user ||
|
|
1048
|
+
entry === ExtraRouteEntries.signature
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Hono path pattern ("/api/foo/:bar", "/api/foo/*") → RegExp. Only the
|
|
1053
|
+
// subset extraRoutes actually uses (static segments, `:param`, trailing
|
|
1054
|
+
// `*`) — no inline `:param{regex}` constraints, no optional `:param?`.
|
|
1055
|
+
function honoPathToRegex(path: string): RegExp {
|
|
1056
|
+
const segments = path
|
|
1057
|
+
.split("/")
|
|
1058
|
+
.map((segment) => {
|
|
1059
|
+
if (segment.startsWith(":")) return "[^/]+";
|
|
1060
|
+
if (segment === "*") return ".*";
|
|
1061
|
+
return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1062
|
+
})
|
|
1063
|
+
.join("/");
|
|
1064
|
+
return new RegExp(`^${segments}$`);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
type ExtraRoutePublicMatcher = { readonly method: string; readonly pattern: RegExp };
|
|
1068
|
+
|
|
1069
|
+
// See the bypass comment at the jwtGuard mount above — only signature
|
|
1070
|
+
// routes are public here.
|
|
1071
|
+
function compileExtraRoutePublicMatchers(
|
|
1072
|
+
extraRoutes: readonly ExtraRouteDefinition[] | undefined,
|
|
1073
|
+
): readonly ExtraRoutePublicMatcher[] {
|
|
1074
|
+
if (!extraRoutes) return [];
|
|
1075
|
+
return extraRoutes
|
|
1076
|
+
.filter((route) => route.entry === ExtraRouteEntries.signature)
|
|
1077
|
+
.map((route) => ({ method: route.method, pattern: honoPathToRegex(route.path) }));
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
type ExtraRouteHonoHandlerDeps = {
|
|
1081
|
+
readonly app: Hono;
|
|
1082
|
+
readonly dispatcher: Dispatcher;
|
|
1083
|
+
readonly registry: Registry;
|
|
1084
|
+
readonly secrets: import("../secrets").SecretsContext | undefined;
|
|
1085
|
+
readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
|
|
1086
|
+
readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
|
|
1087
|
+
};
|
|
1088
|
+
|
|
1089
|
+
function buildExtraRouteHonoHandler(
|
|
1090
|
+
route: ExtraRouteDefinition,
|
|
1091
|
+
shared: ExtraRouteHonoHandlerDeps,
|
|
1092
|
+
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
1093
|
+
): (c: import("hono").Context<any, any>) => Promise<Response> {
|
|
1094
|
+
switch (route.entry) {
|
|
1095
|
+
case ExtraRouteEntries.anonymous:
|
|
1096
|
+
return async (c) =>
|
|
1097
|
+
route.handler(c, {
|
|
1098
|
+
app: shared.app,
|
|
1099
|
+
registry: shared.registry,
|
|
1100
|
+
systemQuery: makeSystemQuery(c, shared.dispatcher),
|
|
1101
|
+
write: makeAnonymousWrite(c, shared.dispatcher),
|
|
1102
|
+
});
|
|
1103
|
+
case ExtraRouteEntries.user:
|
|
1104
|
+
return async (c) => {
|
|
1105
|
+
const user = getUser(c);
|
|
1106
|
+
if (!user || user.roles.includes(ANONYMOUS_ROLE)) {
|
|
1107
|
+
return c.json(
|
|
1108
|
+
{
|
|
1109
|
+
error: {
|
|
1110
|
+
code: "unauthenticated",
|
|
1111
|
+
httpStatus: 401,
|
|
1112
|
+
message: "this route requires a signed-in user",
|
|
1113
|
+
i18nKey: "auth.errors.missingToken",
|
|
1114
|
+
},
|
|
1115
|
+
},
|
|
1116
|
+
401,
|
|
1117
|
+
);
|
|
1118
|
+
}
|
|
1119
|
+
return route.handler(c, {
|
|
1120
|
+
app: shared.app,
|
|
1121
|
+
registry: shared.registry,
|
|
1122
|
+
user,
|
|
1123
|
+
query: (type, payload) => shared.dispatcher.query(type, payload, user),
|
|
1124
|
+
write: (type, payload) => shared.dispatcher.write(type, payload, user),
|
|
1125
|
+
});
|
|
1126
|
+
};
|
|
1127
|
+
case ExtraRouteEntries.signature:
|
|
1128
|
+
return async (c) => {
|
|
1129
|
+
const rawBody = await c.req.text();
|
|
1130
|
+
const headers: Record<string, string> = {};
|
|
1131
|
+
c.req.raw.headers.forEach((value, key) => {
|
|
1132
|
+
headers[key.toLowerCase()] = value;
|
|
1133
|
+
});
|
|
1134
|
+
let verified: unknown;
|
|
1135
|
+
try {
|
|
1136
|
+
verified = await route.verify(
|
|
1137
|
+
{ rawBody, headers, params: c.req.param(), query: c.req.query() },
|
|
1138
|
+
{ registry: shared.registry, secrets: shared.secrets },
|
|
1139
|
+
);
|
|
1140
|
+
} catch (e) {
|
|
1141
|
+
if (e instanceof ExtraRouteRejection) {
|
|
1142
|
+
return c.json(e.body, e.status);
|
|
1143
|
+
}
|
|
1144
|
+
return c.json(
|
|
1145
|
+
{
|
|
1146
|
+
error: {
|
|
1147
|
+
code: "extra_route_signature_invalid",
|
|
1148
|
+
message: e instanceof Error ? e.message : String(e),
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
401,
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
return route.handler(c, verified, {
|
|
1155
|
+
app: shared.app,
|
|
1156
|
+
registry: shared.registry,
|
|
1157
|
+
secrets: shared.secrets,
|
|
1158
|
+
systemQuery: makeSystemQuery(c, shared.dispatcher),
|
|
1159
|
+
dispatchSystemWrite: shared.dispatchSystemWrite,
|
|
1160
|
+
dispatchSystemQuery: shared.dispatchSystemQuery,
|
|
1161
|
+
});
|
|
1162
|
+
};
|
|
1163
|
+
default:
|
|
1164
|
+
return assertUnreachable(route, "extra route entry");
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
903
1168
|
function deriveTenantLifecycleResolver(
|
|
904
1169
|
registry: Registry,
|
|
905
1170
|
db: DbConnection | undefined,
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.298.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "Anonymous extraRoutes get `write`, running as the session the /api chain resolved for this request (never more than that caller could do via /api/write, request-resolved tenant, /api/ only)."
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"version": "0.298.0",
|
|
9
|
+
"type": "breaking",
|
|
10
|
+
"title": "extraRoutes/hostDispatch move to structured route and wire definitions",
|
|
11
|
+
"migration": "extraRoutes on runProdApp/createKumikoServer/runDevApp/setupTestStack changes from (app, deps) => void to readonly ExtraRouteDefinition[]. Each entry is { method, path, entry: \"anonymous\" | \"user\" | \"signature\", handler }, built via the helpers in @cosmicdrift/kumiko-framework/api; a signature route also needs verify(request, deps) via signatureRoute<T>(). An anonymous GET route that used to call app.get(path, handler) on the raw app now receives { app, registry, systemQuery } - replace direct db/redis reads with systemQuery. A route reading user data via a raw db handle now declares entry: \"user\" (path must live under /api/, unauthenticated requests get 401 automatically) and receives { app, registry, user, query, write } instead of db/redis. A route verifying an external signature (webhooks) declares entry: \"signature\" and receives { app, registry, secrets?, systemQuery, dispatchSystemWrite, dispatchSystemQuery }; reject invalid signatures with ExtraRouteRejection(status, body) from verify. Non-route setup that used to run inside the old extraRoutes(app, deps) callback (late-binding, background seeds, starting a runner) moves to the new wire?: (deps: SystemWireDeps) => void | Promise<void> option on runProdApp/createKumikoServer, which gets { db, redis, registry, dispatchSystemWrite } but no app. hostDispatch (dev) and HostDispatchFn (runProdApp) gain a second argument { systemQuery }; an app.use middleware that read db directly for host dispatch now uses systemQuery instead."
|
|
12
|
+
},
|
|
2
13
|
{
|
|
3
14
|
"version": "0.296.0",
|
|
4
15
|
"type": "breaking",
|
|
@@ -5,11 +5,11 @@ export { SYSTEM_USER_ID };
|
|
|
5
5
|
|
|
6
6
|
export const SYSTEM_ROLE = "system" as const;
|
|
7
7
|
|
|
8
|
-
// extraRoles: hasAccess
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// process-event)
|
|
12
|
-
// SYSTEM_USER_ID,
|
|
8
|
+
// extraRoles: hasAccess has no system bypass — handlers gate on
|
|
9
|
+
// explicit roles. Callers that must reach handlers gated e.g. on
|
|
10
|
+
// SystemAdmin (dispatchSystemWrite on entry:"signature" routes or the
|
|
11
|
+
// `wire` hook → billing-foundation process-event) pass the role here
|
|
12
|
+
// additionally; createdBy stays SYSTEM_USER_ID, the audit trail still shows System.
|
|
13
13
|
export function createSystemUser(
|
|
14
14
|
tenantId: TenantId,
|
|
15
15
|
extraRoles: readonly string[] = [],
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -87,6 +87,7 @@ export type ApiEntrypointOptions = BaseEntrypointOptions & {
|
|
|
87
87
|
readonly maxRequestBytes?: ServerOptions["maxRequestBytes"];
|
|
88
88
|
readonly readiness?: ServerOptions["readiness"];
|
|
89
89
|
readonly metrics?: ServerOptions["metrics"];
|
|
90
|
+
readonly extraRoutes?: ServerOptions["extraRoutes"];
|
|
90
91
|
// Job-enqueue surface for the API process. Required whenever the registry
|
|
91
92
|
// defines event-triggered jobs: command-dispatcher fires handleEvent as
|
|
92
93
|
// an afterCommit-hook — without a jobRunner the enqueue silently drops.
|
|
@@ -236,6 +237,7 @@ function buildApiServer(
|
|
|
236
237
|
maxRequestBytes: opts.maxRequestBytes,
|
|
237
238
|
readiness: opts.readiness,
|
|
238
239
|
metrics: opts.metrics,
|
|
240
|
+
extraRoutes: opts.extraRoutes,
|
|
239
241
|
observability: opts.observability,
|
|
240
242
|
observabilityOptions: opts.observabilityOptions,
|
|
241
243
|
dispatcherOptions,
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -42,8 +42,9 @@ export type TestStack = {
|
|
|
42
42
|
// to assert a consumer pushed an invalidation without opening a real SSE
|
|
43
43
|
// connection.
|
|
44
44
|
sseBroker: SseBroker;
|
|
45
|
-
// Command-dispatcher behind the HTTP routes — for direct system-writes
|
|
46
|
-
//
|
|
45
|
+
// Command-dispatcher behind the HTTP routes — for direct system-writes in
|
|
46
|
+
// tests and behind entry:"signature" extraRoutes / the `wire` hook
|
|
47
|
+
// (provider-webhook wiring).
|
|
47
48
|
dispatcher: Dispatcher;
|
|
48
49
|
// The AppContext buildServer handed the request path, incl. the fields it
|
|
49
50
|
// wires itself (_fileProviderResolver). A dev-server that starts its own
|
|
@@ -105,6 +106,10 @@ export type TestStackOptions = {
|
|
|
105
106
|
* The resolver is auto-built from the test Redis. Mirrors
|
|
106
107
|
* buildServer's `rateLimit` option 1:1 — see there for shape. */
|
|
107
108
|
rateLimit?: import("../api/server").ServerOptions["rateLimit"];
|
|
109
|
+
/** Forwarded to buildServer — Integration-Tests that exercise `extraRoutes`
|
|
110
|
+
* MUST go through here (real HTTP via `stack.http`/`stack.app.fetch`),
|
|
111
|
+
* never `createTestDispatcher`. */
|
|
112
|
+
extraRoutes?: import("../api/server").ServerOptions["extraRoutes"];
|
|
108
113
|
/** Inject a MasterKeyProvider for secrets-backed tests. Lands typed in
|
|
109
114
|
* AppContext — set/delete/get + rotation job pick it up. Omit for
|
|
110
115
|
* suites that don't touch secrets. */
|
|
@@ -404,6 +409,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
404
409
|
},
|
|
405
410
|
eventDedup,
|
|
406
411
|
sseBroker,
|
|
412
|
+
...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
|
|
407
413
|
// Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
|
|
408
414
|
// deterministic drains — no timer-induced flakiness. pollIntervalMs
|
|
409
415
|
// stays short anyway in case a test opts into `.start()`. pgClient
|