@cosmicdrift/kumiko-framework 0.299.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__/http-route-entry.integration.test.ts +114 -0
- package/src/api/auth-routes.ts +53 -24
- package/src/api/server.ts +71 -32
- package/src/changes.json +26 -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/types/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/testing/handler-context.ts +3 -1
- package/src/ui-types/index.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.304.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.304.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.304.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.304.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
|
@@ -851,6 +851,7 @@ describe("feature-declared HTTP routes (r.httpRoute)", () => {
|
|
|
851
851
|
r.httpRoute({
|
|
852
852
|
method: "GET",
|
|
853
853
|
path: "/api/forbidden",
|
|
854
|
+
anonymous: true,
|
|
854
855
|
handler: (c) => c.text("nope"),
|
|
855
856
|
});
|
|
856
857
|
}),
|
|
@@ -860,8 +861,8 @@ describe("feature-declared HTTP routes (r.httpRoute)", () => {
|
|
|
860
861
|
test("Boot-Validator: doppelte method+path-Combo wird abgelehnt", () => {
|
|
861
862
|
expect(() =>
|
|
862
863
|
defineFeature("dup", (r) => {
|
|
863
|
-
r.httpRoute({ method: "GET", path: "/x", handler: (c) => c.text("a") });
|
|
864
|
-
r.httpRoute({ method: "GET", path: "/x", handler: (c) => c.text("b") });
|
|
864
|
+
r.httpRoute({ method: "GET", path: "/x", anonymous: true, handler: (c) => c.text("a") });
|
|
865
|
+
r.httpRoute({ method: "GET", path: "/x", anonymous: true, handler: (c) => c.text("b") });
|
|
865
866
|
}),
|
|
866
867
|
).toThrow(/already registered/);
|
|
867
868
|
});
|
|
@@ -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);
|
package/src/api/server.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
1
|
+
import { Hono, type MiddlewareHandler } from "hono";
|
|
2
2
|
import { ROLES } from "../auth/roles";
|
|
3
3
|
import type { DbConnection, PgClient } from "../db/connection";
|
|
4
4
|
import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
@@ -59,7 +59,7 @@ import {
|
|
|
59
59
|
getUser,
|
|
60
60
|
type TenantLifecycleStatusResolver,
|
|
61
61
|
} from "./auth-middleware";
|
|
62
|
-
import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
|
|
62
|
+
import { type AuthRoutesConfig, createAuthRoutes, type LoginRateLimiter } from "./auth-routes";
|
|
63
63
|
import { csrfMiddleware } from "./csrf-middleware";
|
|
64
64
|
import {
|
|
65
65
|
type ExtraRouteDefinition,
|
|
@@ -723,30 +723,24 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
723
723
|
return jwtGuard(c, next);
|
|
724
724
|
});
|
|
725
725
|
|
|
726
|
+
// Without anonymousAccess a missing token 401s instead of falling through as anonymous.
|
|
727
|
+
const sessionOnlyGuard = authMiddleware(jwt, {
|
|
728
|
+
...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
|
|
729
|
+
...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
|
|
730
|
+
...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
|
|
731
|
+
});
|
|
732
|
+
|
|
726
733
|
// PAT rate limiting — runs AFTER the auth guard so the resolved principal is
|
|
727
734
|
// available. Only PAT-authenticated requests are counted (keyed by token id);
|
|
728
735
|
// cookie/JWT users pass through untouched. In-memory limiter is per-instance
|
|
729
736
|
// (see run-prod-app) — a multi-node deployment wanting a shared counter swaps
|
|
730
737
|
// in a Redis-backed LoginRateLimiter.
|
|
731
738
|
const patRateLimiter = options.auth?.patRateLimiter;
|
|
732
|
-
|
|
739
|
+
const patRateLimitGuard = patRateLimiter ? buildPatRateLimitGuard(patRateLimiter) : undefined;
|
|
740
|
+
if (patRateLimitGuard) {
|
|
733
741
|
app.use("/api/*", async (c, next) => {
|
|
734
742
|
if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
|
|
735
|
-
|
|
736
|
-
if (pat && !(await patRateLimiter.check(pat.tokenId))) {
|
|
737
|
-
return c.json(
|
|
738
|
-
{
|
|
739
|
-
error: {
|
|
740
|
-
code: "pat_rate_limited",
|
|
741
|
-
httpStatus: 429,
|
|
742
|
-
message: "personal access token rate limit exceeded",
|
|
743
|
-
i18nKey: "auth.errors.patRateLimited",
|
|
744
|
-
},
|
|
745
|
-
},
|
|
746
|
-
429,
|
|
747
|
-
);
|
|
748
|
-
}
|
|
749
|
-
return next();
|
|
743
|
+
return patRateLimitGuard(c, next);
|
|
750
744
|
});
|
|
751
745
|
}
|
|
752
746
|
|
|
@@ -760,8 +754,9 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
760
754
|
// unguarded-subdomain-XSS footgun, not a warn-and-continue case.
|
|
761
755
|
assertOriginGuardConfig(options.auth);
|
|
762
756
|
const allowedOrigins = options.auth?.allowedOrigins;
|
|
763
|
-
|
|
764
|
-
|
|
757
|
+
const originGuard =
|
|
758
|
+
allowedOrigins && allowedOrigins.length > 0 ? originMiddleware(allowedOrigins) : undefined;
|
|
759
|
+
if (originGuard) {
|
|
765
760
|
app.use("/api/*", async (c, next) => {
|
|
766
761
|
if (PUBLIC_API_PATHS.has(c.req.path) || isExtraRoutePublicPath(c)) return next();
|
|
767
762
|
return originGuard(c, next);
|
|
@@ -781,6 +776,14 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
781
776
|
return csrfGuard(c, next);
|
|
782
777
|
});
|
|
783
778
|
|
|
779
|
+
// Same order as /api/* above: auth → PAT → origin → CSRF.
|
|
780
|
+
const sessionOnlyHttpRouteGuards: readonly MiddlewareHandler[] = [
|
|
781
|
+
sessionOnlyGuard,
|
|
782
|
+
...(patRateLimitGuard ? [patRateLimitGuard] : []),
|
|
783
|
+
...(originGuard ? [originGuard] : []),
|
|
784
|
+
csrfGuard,
|
|
785
|
+
];
|
|
786
|
+
|
|
784
787
|
// Public auth routes (login) need to be registered BEFORE the generic
|
|
785
788
|
// api routes so Hono matches them first.
|
|
786
789
|
if (options.auth) {
|
|
@@ -845,20 +848,25 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
845
848
|
const honoHandler = async (c: import("hono").Context): Promise<Response> =>
|
|
846
849
|
route.handler(c, {
|
|
847
850
|
app,
|
|
848
|
-
// createAnonymousUser, NOT createSystemUser:
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
853
|
-
//
|
|
854
|
-
//
|
|
855
|
-
//
|
|
856
|
-
//
|
|
857
|
-
//
|
|
858
|
-
// is needed or wanted on top of that.
|
|
851
|
+
// createAnonymousUser, NOT createSystemUser: systemQuery's
|
|
852
|
+
// synthesized user must clear the SAME access gate a real
|
|
853
|
+
// anonymous visitor would, no more — regardless of the route's
|
|
854
|
+
// own `anonymous` mode. The system role would ALSO satisfy that
|
|
855
|
+
// gate here, but it can read fields gated to "system" that
|
|
856
|
+
// "anonymous" can't (filterReadFields is a plain role-in-map
|
|
857
|
+
// check) — a systemQuery caller reading a system-gated field
|
|
858
|
+
// would leak it into the response. The forced tenant already
|
|
859
|
+
// comes from bypassing the HTTP layer entirely; no elevated
|
|
860
|
+
// role is needed or wanted on top of that.
|
|
859
861
|
systemQuery: makeSystemQuery(c, dispatcher),
|
|
860
862
|
});
|
|
861
|
-
mountHonoRoute(
|
|
863
|
+
mountHonoRoute(
|
|
864
|
+
app,
|
|
865
|
+
route.method,
|
|
866
|
+
route.path,
|
|
867
|
+
honoHandler,
|
|
868
|
+
route.anonymous ? [] : sessionOnlyHttpRouteGuards,
|
|
869
|
+
);
|
|
862
870
|
}
|
|
863
871
|
}
|
|
864
872
|
|
|
@@ -958,7 +966,17 @@ function mountHonoRoute(
|
|
|
958
966
|
path: string,
|
|
959
967
|
// biome-ignore lint/suspicious/noExplicitAny: Hono context generics are invisible at the framework boundary
|
|
960
968
|
handler: (c: import("hono").Context<any, any>) => Response | Promise<Response>,
|
|
969
|
+
middlewares: readonly MiddlewareHandler[] = [],
|
|
961
970
|
): void {
|
|
971
|
+
// Guards go into the route's own handler chain, never a method-gated
|
|
972
|
+
// app.use: Hono serves HEAD through the GET route with c.req.method still
|
|
973
|
+
// "HEAD", so a `method === c.req.method` gate would skip them for HEAD.
|
|
974
|
+
if (middlewares.length > 0) {
|
|
975
|
+
// [path]: only Hono's array-path overload accepts a variable-length handler spread.
|
|
976
|
+
app.on(method, [path], ...middlewares, handler);
|
|
977
|
+
// skip: guarded route already mounted with its guard chain
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
962
980
|
switch (method) {
|
|
963
981
|
case "GET":
|
|
964
982
|
app.get(path, handler);
|
|
@@ -985,6 +1003,27 @@ function mountHonoRoute(
|
|
|
985
1003
|
}
|
|
986
1004
|
}
|
|
987
1005
|
|
|
1006
|
+
// Must run after the auth guard so getUser(c) carries the PAT.
|
|
1007
|
+
function buildPatRateLimitGuard(patRateLimiter: LoginRateLimiter): MiddlewareHandler {
|
|
1008
|
+
return async (c, next) => {
|
|
1009
|
+
const pat = getUser(c)?.pat;
|
|
1010
|
+
if (pat && !(await patRateLimiter.check(pat.tokenId))) {
|
|
1011
|
+
return c.json(
|
|
1012
|
+
{
|
|
1013
|
+
error: {
|
|
1014
|
+
code: "pat_rate_limited",
|
|
1015
|
+
httpStatus: 429,
|
|
1016
|
+
message: "personal access token rate limit exceeded",
|
|
1017
|
+
i18nKey: "auth.errors.patRateLimited",
|
|
1018
|
+
},
|
|
1019
|
+
},
|
|
1020
|
+
429,
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
return next();
|
|
1024
|
+
};
|
|
1025
|
+
}
|
|
1026
|
+
|
|
988
1027
|
// Shared systemQuery builder for r.httpRoute and extraRoutes (anonymous +
|
|
989
1028
|
// signature entries). requestContext.run must wrap the dispatcher call —
|
|
990
1029
|
// both route kinds run outside the requestIdMiddleware chain that normally
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.303.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "r.httpRoute's `anonymous` field is now required and controls the mount, not just docs (fw#2885)",
|
|
6
|
+
"detail": "HttpRouteDefinition.anonymous changes from an optional, purely-documentary boolean to a required one that drives buildServer's mount. `anonymous: true` stays public, unchanged. `anonymous: false` now mounts the route behind the same session-auth chain /api/* uses: no anonymous fallthrough (a request without a session gets 401, never a synthesized anonymous user), the same PAT rate-limit guard, origin-allowlist guard and double-submit CSRF guard as /api/*, in the same order (auth → PAT → origin → CSRF). The handler reads the caller via getUser(c). feature-ui-extensions' httpRoute() now throws at feature-setup time when `anonymous` is missing or not a boolean (catches JS callers without the TypeScript type). The feature-AST (patterns/render/patcher/extractor/pattern-library) mirrors the field as required; a source file parsed before this change (missing `anonymous`) reads as `anonymous: false` — the safe side — and round-trip rendering always emits the field explicitly.",
|
|
7
|
+
"migration": "Every r.httpRoute({...}) call site must declare anonymous: true | false. Routes that were implicitly public (no field, or anonymous: true) keep working unchanged once anonymous: true is added explicitly — feature-ui-extensions now throws at boot for a route missing the field. Routes meant to require a session must set anonymous: false; a request without a session then gets 401 instead of running (there is no anonymous fallthrough on those routes any more), and a cookie-authenticated state-changing request needs the same X-CSRF-Token as /api/* or gets 403. Feature-AST round-trips: a saved feature file that predates this change parses `anonymous` as false (not true) — audit any httpRoute that was implicitly public and add `anonymous: true` before re-saving through the Designer/AI editor, or the next render will lock it behind the session-auth chain."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.302.0",
|
|
11
|
+
"type": "breaking",
|
|
12
|
+
"title": "Anonymous write handlers whose input accepts a personal-data field must declare access.personalData: \"public-intake\" (fw#2885)",
|
|
13
|
+
"detail": "RoleAccessRule ({ roles, personalData? }) gains an optional personalData?: RoleAccessPersonalData (currently only \"public-intake\"), exported from @cosmicdrift/kumiko-framework/engine and /ui-types alongside OpenToAllPersonalData. validateAccessDeclarations now requires personalData: \"public-intake\" on a write handler whose access.roles includes \"anonymous\" and whose input schema accepts a personal-data field (pii / userOwned / recordOwned) of an entity in the same feature; declaring personalData on the roles form of a query or stream handler is rejected, \"public-intake\" on roles without \"anonymous\" is rejected, and \"tenant-members\" on the roles form is rejected (openToAll keeps accepting only \"tenant-members\"). Unlike the existing openToAll owner-binding exemption, an anonymous handler is never exempted by an owner-bound access.write map: every anonymous caller shares one user.id (\"anonymous\"), so from(\"user:id\", ...) binds no one specific. The feature-AST extractor (readOptionalAccessRule) reads roles.personalData the same way it already reads openToAll.personalData.",
|
|
14
|
+
"migration": "A write handler with \"anonymous\" in access.roles whose input schema accepts a personal-data field of an entity in the same feature now fails boot until it declares access: { roles: [..., \"anonymous\"], personalData: \"public-intake\" }. Owner-binding via from(\"user:id\", \"<column>\") on the entity access.write does not exempt an anonymous handler (it does exempt an openToAll handler) — anonymous requests share a single caller identity, so rely on the handler's required rateLimit (per ip) instead. personalData: \"public-intake\" is only valid on the roles form and only with \"anonymous\" in roles; openToAll keeps accepting only personalData: \"tenant-members\". The check only sees entities of the handler's own feature; anonymous intake into another feature's entity is covered by the follow-up runtime gate (kumiko-framework#3165). No known bundled-feature handler is affected."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"version": "0.301.0",
|
|
18
|
+
"type": "improvement",
|
|
19
|
+
"title": "Add shared /metrics wiring: prometheusMetricsEnvSchema and resolveObservabilityWiring under @cosmicdrift/kumiko-framework/observability",
|
|
20
|
+
"detail": "Apps that expose a Prometheus /metrics endpoint no longer need to hand-roll the fail-closed wiring. Compose prometheusMetricsEnvSchema.shape into your app's env extend block (extend: appSchema.extend(prometheusMetricsEnvSchema.shape)) and spread resolveObservabilityWiring(env.PROMETHEUS_METRICS_TOKEN) into runProdApp. Without a token the endpoint stays off; publicstatus can now drop its local copy of this wiring (publicstatus#479)."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.300.0",
|
|
24
|
+
"type": "breaking",
|
|
25
|
+
"title": "Remove the guest-identity all-role: unauthenticated handlers must declare roles: [\"anonymous\"] with a rateLimit",
|
|
26
|
+
"migration": "Handlers declared with access: { roles: [\"all\"] } now fail boot — no session ever carries the role \"all\", so this is unreachable dead config, not a wildcard. Switch to access: { roles: [\"anonymous\"] } plus rateLimit: { per: \"ip\" | \"ip+handler\", limit: N, windowSeconds: N } for unauthenticated callers, or access: { openToAll: { reason: \"...\" } } for any signed-in user. Test fixtures that hand-roll a SessionUser with roles: [\"all\"] (bridgeStub, hand-rolled guest literals) must switch to createAnonymousUser(tenantId) or roles: [\"anonymous\"]. buildSessionRoles now also strips \"anonymous\" and \"all\" out of globalRoles at every JWT mint (membership roles were already stripped). auth-routes.ts now dispatches every public /auth/* write with createAnonymousUser(SYSTEM_TENANT_ID) instead of the removed GUEST_USER constant."
|
|
27
|
+
},
|
|
2
28
|
{
|
|
3
29
|
"version": "0.298.0",
|
|
4
30
|
"type": "improvement",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineFeature } from "../define-feature";
|
|
3
|
+
|
|
4
|
+
describe("r.httpRoute — anonymous is required", () => {
|
|
5
|
+
test("missing anonymous → throws (JS caller without HttpRouteDefinition's type)", () => {
|
|
6
|
+
expect(() =>
|
|
7
|
+
defineFeature("feed", (r) => {
|
|
8
|
+
r.httpRoute({
|
|
9
|
+
method: "GET",
|
|
10
|
+
path: "/feed.xml",
|
|
11
|
+
handler: async () => new Response("ok"),
|
|
12
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
13
|
+
} as any);
|
|
14
|
+
}),
|
|
15
|
+
).toThrow(/must declare anonymous: true \| false/);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("anonymous: true → accepted", () => {
|
|
19
|
+
expect(() =>
|
|
20
|
+
defineFeature("feed", (r) => {
|
|
21
|
+
r.httpRoute({
|
|
22
|
+
method: "GET",
|
|
23
|
+
path: "/feed.xml",
|
|
24
|
+
anonymous: true,
|
|
25
|
+
handler: async () => new Response("ok"),
|
|
26
|
+
});
|
|
27
|
+
}),
|
|
28
|
+
).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("anonymous: false → accepted", () => {
|
|
32
|
+
expect(() =>
|
|
33
|
+
defineFeature("feed", (r) => {
|
|
34
|
+
r.httpRoute({
|
|
35
|
+
method: "GET",
|
|
36
|
+
path: "/feed.xml",
|
|
37
|
+
anonymous: false,
|
|
38
|
+
handler: async () => new Response("ok"),
|
|
39
|
+
});
|
|
40
|
+
}),
|
|
41
|
+
).not.toThrow();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -38,10 +38,9 @@ describe("forbidden membership roles", () => {
|
|
|
38
38
|
});
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
describe("merge semantics (globalRoles never filtered)", () => {
|
|
41
|
+
// globalRoles keeps SystemAdmin/system but loses anonymous/all; membershipRoles
|
|
42
|
+
// strips all forbidden roles (SystemAdmin, system, anonymous, all).
|
|
43
|
+
describe("merge semantics (globalRoles: SystemAdmin/system kept, anonymous/all stripped)", () => {
|
|
45
44
|
test("global SystemAdmin survives (no regression for real admins)", () => {
|
|
46
45
|
expect(buildSessionRoles(["SystemAdmin"], [])).toContain("SystemAdmin");
|
|
47
46
|
});
|
|
@@ -55,4 +54,14 @@ describe("merge semantics (globalRoles never filtered)", () => {
|
|
|
55
54
|
["Admin", "SystemAdmin"].sort(),
|
|
56
55
|
);
|
|
57
56
|
});
|
|
57
|
+
|
|
58
|
+
test("anonymous/all in globalRoles are stripped, SystemAdmin stays", () => {
|
|
59
|
+
expect([...buildSessionRoles(["anonymous", "all", "SystemAdmin"], [])].sort()).toEqual([
|
|
60
|
+
"SystemAdmin",
|
|
61
|
+
]);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("anonymous/all in membershipRoles are stripped too", () => {
|
|
65
|
+
expect(buildSessionRoles([], ["anonymous", "all", "Admin"])).toEqual(["Admin"]);
|
|
66
|
+
});
|
|
58
67
|
});
|