@cosmicdrift/kumiko-framework 0.297.0 → 0.304.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__/api.test.ts +3 -2
- package/src/api/__tests__/body-limit.test.ts +4 -4
- package/src/api/__tests__/extra-routes.integration.test.ts +584 -0
- package/src/api/__tests__/http-route-entry.integration.test.ts +114 -0
- package/src/api/auth-routes.ts +53 -24
- package/src/api/extra-route.ts +146 -0
- package/src/api/index.ts +16 -1
- package/src/api/server.ts +376 -72
- package/src/changes.json +37 -0
- package/src/engine/__tests__/http-route-anonymous-required.test.ts +43 -0
- package/src/engine/__tests__/membership-roles.test.ts +13 -4
- package/src/engine/boot-validator/__tests__/access-declarations.test.ts +127 -0
- package/src/engine/boot-validator/__tests__/no-all-role-in-handler-access.test.ts +77 -0
- package/src/engine/boot-validator/access-declarations.ts +59 -7
- package/src/engine/boot-validator/entity-handler.ts +20 -0
- package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/read-optional-access-rule.test.ts +14 -0
- package/src/engine/feature-ast/extractors/hooks.ts +3 -1
- package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -2
- package/src/engine/feature-ast/patcher.ts +2 -2
- package/src/engine/feature-ast/patterns.ts +1 -1
- package/src/engine/feature-ast/render.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +6 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/membership-roles.ts +20 -4
- package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
- package/src/engine/pattern-library/mixed-schemas.ts +1 -0
- package/src/engine/system-user.ts +5 -5
- package/src/engine/types/index.ts +2 -0
- package/src/entrypoint/index.ts +2 -0
- package/src/observability/__tests__/metrics-wiring.test.ts +61 -0
- package/src/observability/index.ts +5 -0
- package/src/observability/metrics-wiring.ts +32 -0
- package/src/stack/test-stack.ts +8 -2
- package/src/testing/handler-context.ts +3 -1
- package/src/ui-types/index.ts +2 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// kumiko-framework#2885 step 3: `HttpRouteDefinition.anonymous` now controls
|
|
2
|
+
// the mount, not just docs/boot-validation. `anonymous: false` must sit
|
|
3
|
+
// behind the SAME session-auth chain /api/* uses (no anonymous fallthrough,
|
|
4
|
+
// PAT rate limit, origin + CSRF guards) — a request without a session gets
|
|
5
|
+
// 401, never a synthesized anonymous user. `anonymous: true` stays public.
|
|
6
|
+
//
|
|
7
|
+
// anonymousAccess is deliberately wired on the test stack: without it, a
|
|
8
|
+
// wrongly-mounted `anonymous: false` route (using jwtGuard instead of
|
|
9
|
+
// sessionOnlyGuard) would ALSO 401 on a missing token, masking the bug the
|
|
10
|
+
// 401 test exists to catch.
|
|
11
|
+
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { defineFeature } from "../../engine";
|
|
14
|
+
import type { TenantId } from "../../engine/types/identifiers";
|
|
15
|
+
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
16
|
+
import { AUTH_COOKIE_NAME, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, getUser } from "../auth-middleware";
|
|
17
|
+
|
|
18
|
+
const TENANT_ID = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
19
|
+
|
|
20
|
+
const entryFeature = defineFeature("http-route-entry", (r) => {
|
|
21
|
+
r.httpRoute({
|
|
22
|
+
method: "GET",
|
|
23
|
+
path: "/entry-public",
|
|
24
|
+
anonymous: true,
|
|
25
|
+
handler: async (c) => c.json({ ok: true }),
|
|
26
|
+
});
|
|
27
|
+
r.httpRoute({
|
|
28
|
+
method: "GET",
|
|
29
|
+
path: "/entry-private",
|
|
30
|
+
anonymous: false,
|
|
31
|
+
handler: async (c) => c.json({ id: getUser(c).id }),
|
|
32
|
+
});
|
|
33
|
+
r.httpRoute({
|
|
34
|
+
method: "POST",
|
|
35
|
+
path: "/entry-private",
|
|
36
|
+
anonymous: false,
|
|
37
|
+
handler: async (c) => c.json({ id: getUser(c).id }),
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
let stack: TestStack;
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
stack = await setupTestStack({
|
|
45
|
+
features: [entryFeature],
|
|
46
|
+
anonymousAccess: { defaultTenantId: TENANT_ID },
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(async () => {
|
|
51
|
+
await stack.cleanup();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("r.httpRoute anonymous:false — session auth chain", () => {
|
|
55
|
+
test("GET without a session → 401, no anonymous fallthrough", async () => {
|
|
56
|
+
const res = await stack.app.request("/entry-private");
|
|
57
|
+
expect(res.status).toBe(401);
|
|
58
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
59
|
+
expect(body.error.code).toBe("missing_token");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Hono answers HEAD through the GET route while c.req.method stays "HEAD";
|
|
63
|
+
// the guard chain must still run for it.
|
|
64
|
+
test("HEAD without a session → 401, not the GET handler unguarded", async () => {
|
|
65
|
+
const res = await stack.app.request("/entry-private", { method: "HEAD" });
|
|
66
|
+
expect(res.status).toBe(401);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("GET with a real session → 200, handler sees the logged-in user, not anonymous", async () => {
|
|
70
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
71
|
+
const res = await stack.app.request("/entry-private", {
|
|
72
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
73
|
+
});
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const body = (await res.json()) as { id: string };
|
|
76
|
+
expect(body.id).toBe(TestUsers.user.id);
|
|
77
|
+
expect(body.id).not.toBe("anonymous");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("POST with cookie auth, no CSRF token → 403 csrf_token_mismatch", async () => {
|
|
81
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
82
|
+
const res = await stack.app.request("/entry-private", {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { Cookie: `${AUTH_COOKIE_NAME}=${token}` },
|
|
85
|
+
});
|
|
86
|
+
expect(res.status).toBe(403);
|
|
87
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
88
|
+
expect(body.error.code).toBe("csrf_token_mismatch");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("POST with cookie auth + matching CSRF token → 200", async () => {
|
|
92
|
+
const token = await stack.jwt.sign(TestUsers.user);
|
|
93
|
+
const csrf = "csrf-fixed-http-route-entry-token";
|
|
94
|
+
const res = await stack.app.request("/entry-private", {
|
|
95
|
+
method: "POST",
|
|
96
|
+
headers: {
|
|
97
|
+
Cookie: `${AUTH_COOKIE_NAME}=${token}; ${CSRF_COOKIE_NAME}=${csrf}`,
|
|
98
|
+
[CSRF_HEADER_NAME]: csrf,
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
expect(res.status).toBe(200);
|
|
102
|
+
const body = (await res.json()) as { id: string };
|
|
103
|
+
expect(body.id).toBe(TestUsers.user.id);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("r.httpRoute anonymous:true — stays public", () => {
|
|
108
|
+
test("GET without a session → 200", async () => {
|
|
109
|
+
const res = await stack.app.request("/entry-public");
|
|
110
|
+
expect(res.status).toBe(200);
|
|
111
|
+
const body = (await res.json()) as { ok: boolean };
|
|
112
|
+
expect(body.ok).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { deleteCookie, setCookie } from "hono/cookie";
|
|
|
5
5
|
import type Redis from "ioredis";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import { buildSessionRoles } from "../engine/membership-roles";
|
|
8
|
-
import { createSystemUser } from "../engine/system-user";
|
|
8
|
+
import { createAnonymousUser, createSystemUser } from "../engine/system-user";
|
|
9
9
|
import {
|
|
10
10
|
type ActiveMembershipRejection,
|
|
11
11
|
type ActiveMembershipResult,
|
|
@@ -197,15 +197,6 @@ type MembershipRow = {
|
|
|
197
197
|
tenantKey?: string;
|
|
198
198
|
};
|
|
199
199
|
|
|
200
|
-
// Guest identity used for unauthenticated calls (e.g. login). The "all" role
|
|
201
|
-
// lets framework access checks pass for handlers declared with roles: ["all"].
|
|
202
|
-
// `id` is the zero-uuid so it flows through event-store columns cleanly.
|
|
203
|
-
const GUEST_USER: SessionUser = {
|
|
204
|
-
id: "00000000-0000-0000-0000-000000000000",
|
|
205
|
-
tenantId: SYSTEM_TENANT_ID,
|
|
206
|
-
roles: ["all"],
|
|
207
|
-
};
|
|
208
|
-
|
|
209
200
|
// Pluggable rate-limiter for POST /auth/login. Returning `false` blocks the
|
|
210
201
|
// request with 429 before the login handler runs — use this to slow down
|
|
211
202
|
// brute-force attempts. The framework ships a default in-memory impl; apps
|
|
@@ -780,7 +771,7 @@ export function createAuthRoutes(
|
|
|
780
771
|
}
|
|
781
772
|
}
|
|
782
773
|
|
|
783
|
-
const result = await dispatcher.write(loginQn, body,
|
|
774
|
+
const result = await dispatcher.write(loginQn, body, createAnonymousUser(SYSTEM_TENANT_ID));
|
|
784
775
|
|
|
785
776
|
if (!result.isSuccess) {
|
|
786
777
|
// Feature-specific auth reason codes arrive via UnprocessableError.details.reason
|
|
@@ -845,7 +836,7 @@ export function createAuthRoutes(
|
|
|
845
836
|
}
|
|
846
837
|
|
|
847
838
|
// POST /auth/mfa/verify — completes a two-step login. Mirrors /auth/login
|
|
848
|
-
// structurally (public,
|
|
839
|
+
// structurally (public, anonymous identity dispatch, mintSessionAndRespond on
|
|
849
840
|
// success) but with its OWN rate limiter (mfaVerifyRateLimit) — this route
|
|
850
841
|
// never goes through a dispatcher write-handler's own rateLimit config,
|
|
851
842
|
// so without this it would have NO rate limiting at all. Per-account
|
|
@@ -877,7 +868,11 @@ export function createAuthRoutes(
|
|
|
877
868
|
}
|
|
878
869
|
}
|
|
879
870
|
|
|
880
|
-
const result = await dispatcher.write(
|
|
871
|
+
const result = await dispatcher.write(
|
|
872
|
+
mfaVerifyQn,
|
|
873
|
+
body,
|
|
874
|
+
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
875
|
+
);
|
|
881
876
|
|
|
882
877
|
if (!result.isSuccess) {
|
|
883
878
|
// @cast-boundary error-details — KumikoError.details shape is per-error
|
|
@@ -954,7 +949,11 @@ export function createAuthRoutes(
|
|
|
954
949
|
}
|
|
955
950
|
}
|
|
956
951
|
|
|
957
|
-
const result = await dispatcher.write(
|
|
952
|
+
const result = await dispatcher.write(
|
|
953
|
+
mfaPreauthEnableStartQn,
|
|
954
|
+
body,
|
|
955
|
+
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
956
|
+
);
|
|
958
957
|
|
|
959
958
|
if (!result.isSuccess) {
|
|
960
959
|
// @cast-boundary error-details — KumikoError.details shape is per-error
|
|
@@ -985,7 +984,7 @@ export function createAuthRoutes(
|
|
|
985
984
|
|
|
986
985
|
// POST /auth/mfa/preauth-confirm — completes both the enrollment started
|
|
987
986
|
// by preauth-enable-start AND the login mfa-setup-required blocked.
|
|
988
|
-
// Mirrors /auth/mfa/verify structurally (public,
|
|
987
|
+
// Mirrors /auth/mfa/verify structurally (public, anonymous identity dispatch,
|
|
989
988
|
// mintSessionAndRespond on success) with its OWN rate limiter
|
|
990
989
|
// (mfaPreauthConfirmRateLimit) — same reasoning as mfaVerifyRateLimit.
|
|
991
990
|
// Per-account brute-force protection (capping wrong-code guesses against
|
|
@@ -1016,7 +1015,11 @@ export function createAuthRoutes(
|
|
|
1016
1015
|
}
|
|
1017
1016
|
}
|
|
1018
1017
|
|
|
1019
|
-
const result = await dispatcher.write(
|
|
1018
|
+
const result = await dispatcher.write(
|
|
1019
|
+
mfaPreauthConfirmQn,
|
|
1020
|
+
body,
|
|
1021
|
+
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
1022
|
+
);
|
|
1020
1023
|
|
|
1021
1024
|
if (!result.isSuccess) {
|
|
1022
1025
|
// @cast-boundary error-details — KumikoError.details shape is per-error
|
|
@@ -1128,7 +1131,11 @@ export function createAuthRoutes(
|
|
|
1128
1131
|
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
1129
1132
|
}
|
|
1130
1133
|
|
|
1131
|
-
const result = await dispatcher.write(
|
|
1134
|
+
const result = await dispatcher.write(
|
|
1135
|
+
sg.confirmHandler,
|
|
1136
|
+
parsed.data,
|
|
1137
|
+
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
1138
|
+
);
|
|
1132
1139
|
|
|
1133
1140
|
if (!result.isSuccess) {
|
|
1134
1141
|
// 422 für invalid_signup_token (handler-level UnprocessableError).
|
|
@@ -1210,7 +1217,11 @@ export function createAuthRoutes(
|
|
|
1210
1217
|
if (!parsed.success) {
|
|
1211
1218
|
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
1212
1219
|
}
|
|
1213
|
-
const result = await dispatcher.write(
|
|
1220
|
+
const result = await dispatcher.write(
|
|
1221
|
+
inv.acceptWithLoginHandler,
|
|
1222
|
+
parsed.data,
|
|
1223
|
+
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
1224
|
+
);
|
|
1214
1225
|
if (!result.isSuccess) {
|
|
1215
1226
|
const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
|
|
1216
1227
|
return c.json({ isSuccess: false, error: result.error }, status);
|
|
@@ -1258,7 +1269,11 @@ export function createAuthRoutes(
|
|
|
1258
1269
|
if (!parsed.success) {
|
|
1259
1270
|
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
1260
1271
|
}
|
|
1261
|
-
const result = await dispatcher.write(
|
|
1272
|
+
const result = await dispatcher.write(
|
|
1273
|
+
inv.signupCompleteHandler,
|
|
1274
|
+
parsed.data,
|
|
1275
|
+
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
1276
|
+
);
|
|
1262
1277
|
if (!result.isSuccess) {
|
|
1263
1278
|
const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
|
|
1264
1279
|
return c.json({ isSuccess: false, error: result.error }, status);
|
|
@@ -1457,10 +1472,20 @@ function registerTokenRequestRoute(opts: {
|
|
|
1457
1472
|
if (!parsed.success) return c.json({ isSuccess: true });
|
|
1458
1473
|
|
|
1459
1474
|
// The handler dispatches the magic-link mail via delivery before returning.
|
|
1460
|
-
// Handler-level failures (
|
|
1461
|
-
//
|
|
1462
|
-
//
|
|
1463
|
-
|
|
1475
|
+
// Handler-level failures (misconfiguration, e.g. no RateLimitResolver) stay
|
|
1476
|
+
// out of the response so it is uniform for unknown vs. known emails, but
|
|
1477
|
+
// they are logged — otherwise the mail silently never goes out. No email in
|
|
1478
|
+
// the log line (PII).
|
|
1479
|
+
const result = await opts.dispatcher.write(
|
|
1480
|
+
opts.requestHandler,
|
|
1481
|
+
{ email: parsed.data.email },
|
|
1482
|
+
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
1483
|
+
);
|
|
1484
|
+
if (!result.isSuccess) {
|
|
1485
|
+
console.error(
|
|
1486
|
+
`[kumiko] token request handler "${opts.requestHandler}" failed: ${result.error.code}`,
|
|
1487
|
+
);
|
|
1488
|
+
}
|
|
1464
1489
|
|
|
1465
1490
|
return c.json({ isSuccess: true });
|
|
1466
1491
|
});
|
|
@@ -1480,7 +1505,11 @@ function registerTokenConfirmRoute(opts: {
|
|
|
1480
1505
|
if (!parsed.success) {
|
|
1481
1506
|
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
1482
1507
|
}
|
|
1483
|
-
const result = await opts.dispatcher.write(
|
|
1508
|
+
const result = await opts.dispatcher.write(
|
|
1509
|
+
opts.confirmHandler,
|
|
1510
|
+
parsed.data,
|
|
1511
|
+
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
1512
|
+
);
|
|
1484
1513
|
if (!result.isSuccess) {
|
|
1485
1514
|
const status = result.error.httpStatus as 400 | 401 | 403 | 422 | 500; // @cast-boundary engine-payload
|
|
1486
1515
|
return c.json({ isSuccess: false, error: result.error }, status);
|
|
@@ -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";
|