@cosmicdrift/kumiko-framework 0.201.0 → 0.203.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 +7 -3
- package/src/api/__tests__/api-constants-completeness.test.ts +63 -0
- package/src/api/__tests__/body-limit.test.ts +78 -4
- package/src/api/__tests__/server-jwt-ttl.test.ts +2 -2
- package/src/api/api-constants.ts +44 -7
- package/src/api/auth-middleware.ts +19 -3
- package/src/api/index.ts +1 -0
- package/src/api/route-registrars.ts +19 -22
- package/src/api/server.ts +1 -1
- package/src/bun-db/__tests__/sql-expr-brand.test.ts +33 -1
- package/src/db/__tests__/list-pagination.test.ts +28 -0
- package/src/db/dialect.ts +7 -8
- package/src/db/entity-table-meta.ts +4 -3
- package/src/db/event-store-executor-read.ts +26 -2
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +22 -0
- package/src/engine/__tests__/boot-validator-detail-for.test.ts +82 -0
- package/src/engine/__tests__/boot-validator-projection-list.test.ts +191 -0
- package/src/engine/__tests__/build-app-schema.test.ts +119 -0
- package/src/engine/__tests__/projection-detail-actions.test.ts +137 -0
- package/src/engine/boot-validator/action-wiring.ts +7 -1
- package/src/engine/boot-validator/detail-screens.ts +35 -0
- package/src/engine/boot-validator/index.ts +4 -0
- package/src/engine/boot-validator/projection-list-screens.ts +82 -0
- package/src/engine/boot-validator/screens.ts +42 -1
- package/src/engine/build-app-schema.ts +55 -2
- package/src/engine/feature-ast/__tests__/patch.test.ts +58 -0
- package/src/engine/feature-ast/patch.ts +22 -2
- package/src/files/__tests__/files.integration.test.ts +2 -2
- package/src/http/__tests__/egress-real-endpoint.integration.test.ts +37 -0
- package/src/http/__tests__/egress.test.ts +440 -0
- package/src/http/__tests__/policy.test.ts +125 -0
- package/src/http/egress.ts +158 -0
- package/src/http/index.ts +2 -0
- package/src/http/policy.ts +193 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.203.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>",
|
|
@@ -87,6 +87,10 @@
|
|
|
87
87
|
"types": "./src/i18n/index.ts",
|
|
88
88
|
"default": "./src/i18n/index.ts"
|
|
89
89
|
},
|
|
90
|
+
"./http": {
|
|
91
|
+
"types": "./src/http/index.ts",
|
|
92
|
+
"default": "./src/http/index.ts"
|
|
93
|
+
},
|
|
90
94
|
"./auth": {
|
|
91
95
|
"types": "./src/auth/index.ts",
|
|
92
96
|
"default": "./src/auth/index.ts"
|
|
@@ -186,7 +190,7 @@
|
|
|
186
190
|
"./package.json": "./package.json"
|
|
187
191
|
},
|
|
188
192
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
193
|
+
"@cosmicdrift/kumiko-types": "0.203.0",
|
|
190
194
|
"bullmq": "^5.76.7",
|
|
191
195
|
"bun-types": "^1.3.13",
|
|
192
196
|
"hono": "^4.13.1",
|
|
@@ -202,7 +206,7 @@
|
|
|
202
206
|
"zod": "^4.4.3"
|
|
203
207
|
},
|
|
204
208
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
209
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.203.0",
|
|
206
210
|
"bun-types": "^1.3.13",
|
|
207
211
|
"pino-pretty": "^13.1.3"
|
|
208
212
|
},
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { NON_PUBLIC_API_PATHS, PUBLIC_API_PATHS, Routes } from "../api-constants";
|
|
3
|
+
|
|
4
|
+
// PUBLIC_API_PATHS is an allowlist: a missing entry (typo, forgotten route)
|
|
5
|
+
// fails CLOSED — the route stays behind auth, never accidentally public.
|
|
6
|
+
// But the classification itself must be total, or a route silently falls
|
|
7
|
+
// into a third, unchecked state that nobody notices until it's either
|
|
8
|
+
// exploited (should've been non-public) or reported broken by a client
|
|
9
|
+
// (should've been public). This test forces every `Routes` entry into
|
|
10
|
+
// exactly one of the two sets, in both directions:
|
|
11
|
+
// - every Routes entry has a classification (no silent gap)
|
|
12
|
+
// - every classified path still corresponds to a real Routes entry (no
|
|
13
|
+
// stale/typo'd literal drifting out of sync with Routes)
|
|
14
|
+
function classifyRoutes(routes: Record<string, string>): {
|
|
15
|
+
unclassified: string[];
|
|
16
|
+
classifiedInBoth: string[];
|
|
17
|
+
} {
|
|
18
|
+
const unclassified: string[] = [];
|
|
19
|
+
const classifiedInBoth: string[] = [];
|
|
20
|
+
|
|
21
|
+
for (const routePath of Object.values(routes)) {
|
|
22
|
+
const apiPath = `/api${routePath}`;
|
|
23
|
+
const isPublic = PUBLIC_API_PATHS.has(apiPath);
|
|
24
|
+
const isNonPublic = NON_PUBLIC_API_PATHS.has(apiPath);
|
|
25
|
+
if (isPublic && isNonPublic) classifiedInBoth.push(apiPath);
|
|
26
|
+
else if (!isPublic && !isNonPublic) unclassified.push(apiPath);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { unclassified, classifiedInBoth };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("Routes / PUBLIC_API_PATHS classification completeness", () => {
|
|
33
|
+
test("every Routes entry is classified as exactly public XOR non-public", () => {
|
|
34
|
+
const { unclassified, classifiedInBoth } = classifyRoutes(Routes);
|
|
35
|
+
|
|
36
|
+
expect(unclassified).toEqual([]);
|
|
37
|
+
expect(classifiedInBoth).toEqual([]);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("every PUBLIC_API_PATHS / NON_PUBLIC_API_PATHS entry maps back to a real Routes value", () => {
|
|
41
|
+
const knownApiPaths = new Set(Object.values(Routes).map((routePath) => `/api${routePath}`));
|
|
42
|
+
|
|
43
|
+
const stalePublic = [...PUBLIC_API_PATHS].filter((path) => !knownApiPaths.has(path));
|
|
44
|
+
const staleNonPublic = [...NON_PUBLIC_API_PATHS].filter((path) => !knownApiPaths.has(path));
|
|
45
|
+
|
|
46
|
+
expect(stalePublic).toEqual([]);
|
|
47
|
+
expect(staleNonPublic).toEqual([]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Regression guard for the mechanism itself: proves the completeness
|
|
51
|
+
// check actually fails when a route is added without a classification,
|
|
52
|
+
// rather than the two tests above being vacuously true by construction.
|
|
53
|
+
test("regression: an unclassified route is detected", () => {
|
|
54
|
+
const routesWithGap = {
|
|
55
|
+
...Routes,
|
|
56
|
+
newFeature: "/new-feature-without-classification",
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const { unclassified } = classifyRoutes(routesWithGap);
|
|
60
|
+
|
|
61
|
+
expect(unclassified).toEqual(["/api/new-feature-without-classification"]);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
|
|
4
|
+
import { BODY_LIMIT_OPT_OUT_PATHS, Routes } from "../api-constants";
|
|
4
5
|
import { buildServer } from "../server";
|
|
5
6
|
|
|
6
7
|
const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
|
|
@@ -86,10 +87,12 @@ describe("request body limit", () => {
|
|
|
86
87
|
expect(res.status).toBe(401); // passes body-limit, reaches auth
|
|
87
88
|
});
|
|
88
89
|
|
|
89
|
-
// Security regression (2026-08-13 audit, Finding 2): /api/stream
|
|
90
|
-
// missing from BODY_LIMIT_PATHS, so it dispatched to the
|
|
91
|
-
// without ever hitting the same 1MB cap /api/write enforces.
|
|
92
|
-
// /api/write cases above.
|
|
90
|
+
// Security regression (2026-08-13 audit, Finding 2): /api/stream used to be
|
|
91
|
+
// missing from the old BODY_LIMIT_PATHS allowlist, so it dispatched to the
|
|
92
|
+
// dispatcher without ever hitting the same 1MB cap /api/write enforces.
|
|
93
|
+
// Mirrors the /api/write cases above. Now covered structurally (see
|
|
94
|
+
// "default coverage sweep" below), kept as its own test for the historical
|
|
95
|
+
// regression it documents.
|
|
93
96
|
test("rejects POST /api/stream with body larger than maxRequestBytes with 413 (mirrors /api/write)", async () => {
|
|
94
97
|
const app = buildApp(1024);
|
|
95
98
|
const res = await postJson(app, "/api/stream", 2048);
|
|
@@ -102,3 +105,74 @@ describe("request body limit", () => {
|
|
|
102
105
|
expect(res.status).toBe(401); // no JWT → 401, but size is fine
|
|
103
106
|
});
|
|
104
107
|
});
|
|
108
|
+
|
|
109
|
+
// fw#2145: BODY_LIMIT_PATHS inverted from an opt-in allowlist to an opt-out
|
|
110
|
+
// list — registerBodyLimit now mounts on /api/* by construction, and only
|
|
111
|
+
// BODY_LIMIT_OPT_OUT_PATHS (api-constants.ts) escapes it. These tests prove
|
|
112
|
+
// that inversion rather than re-testing individual paths.
|
|
113
|
+
describe("body-limit opt-out completeness", () => {
|
|
114
|
+
test("every opt-out entry resolves to a real Routes constant (no stale/typo'd paths)", () => {
|
|
115
|
+
for (const optOutPath of BODY_LIMIT_OPT_OUT_PATHS) {
|
|
116
|
+
const matchesKnownRoute = Object.values(Routes).some(
|
|
117
|
+
(route) => `/api${route}` === optOutPath,
|
|
118
|
+
);
|
|
119
|
+
expect(matchesKnownRoute).toBe(true);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("the opt-out list is pinned to its reviewed members — a new exception must touch this test", () => {
|
|
124
|
+
expect([...BODY_LIMIT_OPT_OUT_PATHS]).toEqual([`/api${Routes.files}`]);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe("default coverage sweep — proves the default, not a hand-maintained list", () => {
|
|
129
|
+
const OVERSIZED_BYTES = 2_000_000; // exceeds the default 1 MiB cap
|
|
130
|
+
|
|
131
|
+
// Derived from Routes itself (minus opt-out) rather than a hand-picked
|
|
132
|
+
// list — a route added to Routes tomorrow lands in this sweep
|
|
133
|
+
// automatically, with no test file to remember to update. Includes routes
|
|
134
|
+
// this test app never mounts (e.g. authLogin, no `auth` option passed to
|
|
135
|
+
// buildServer) and routes whose GET handler is mounted outside /api/*
|
|
136
|
+
// (health, healthReady, version — registerHealthRoutes/registerVersionRoute
|
|
137
|
+
// mount at the bare path, not under /api). Both still 413 on an oversized
|
|
138
|
+
// POST here: the /api/* body-limit middleware matches on path prefix
|
|
139
|
+
// before Hono resolves a handler, so it runs whether or not a route
|
|
140
|
+
// answers underneath. Verified directly: `POST /api/health` 413s even
|
|
141
|
+
// though the only real handler lives at `GET /health`.
|
|
142
|
+
const defaultLimitedRoutes = Object.values(Routes).filter(
|
|
143
|
+
(route) => !BODY_LIMIT_OPT_OUT_PATHS.has(`/api${route}`),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
for (const route of defaultLimitedRoutes) {
|
|
147
|
+
test(`POST /api${route} 413s on an oversized body without needing its own list entry`, async () => {
|
|
148
|
+
const app = buildApp();
|
|
149
|
+
const res = await postJson(app, `/api${route}`, OVERSIZED_BYTES);
|
|
150
|
+
expect(res.status).toBe(413);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
test("POST /api/files does not 413 on an oversized JSON body (explicit opt-out)", async () => {
|
|
155
|
+
const app = buildApp();
|
|
156
|
+
const res = await postJson(app, "/api/files", OVERSIZED_BYTES);
|
|
157
|
+
expect(res.status).not.toBe(413);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Regression for the DoD: "neue Route ohne Eintrag in irgendeiner Liste
|
|
161
|
+
// bekommt automatisch ein Limit". Mounts a route the same way an app-owner's
|
|
162
|
+
// `extraRoutes` callback would — after buildServer, with zero Routes/
|
|
163
|
+
// opt-out entries — and proves it inherits the cap AND still serves a
|
|
164
|
+
// small body correctly (not an accidental always-413).
|
|
165
|
+
test("a route mounted after buildServer with no list entry anywhere still inherits the default limit", async () => {
|
|
166
|
+
const app = buildApp();
|
|
167
|
+
app.post("/api/totally-new-route-nobody-listed", async (c) => c.json({ ok: true }));
|
|
168
|
+
|
|
169
|
+
const oversized = await postJson(app, "/api/totally-new-route-nobody-listed", OVERSIZED_BYTES);
|
|
170
|
+
expect(oversized.status).toBe(413);
|
|
171
|
+
|
|
172
|
+
// Small body passes the size cap and reaches the auth guard (401, no
|
|
173
|
+
// JWT) — proves the 413 above is the size cap doing its job, not the
|
|
174
|
+
// route being unreachable for some unrelated reason.
|
|
175
|
+
const small = await postJson(app, "/api/totally-new-route-nobody-listed", 10);
|
|
176
|
+
expect(small.status).toBe(401);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
@@ -21,7 +21,7 @@ describe("buildServer — jwtTtl default depends on sessionChecker wiring", () =
|
|
|
21
21
|
expect(jwt.ttlSeconds).toBe(60 * 60);
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
test("auth with sessionChecker → session-backed default (
|
|
24
|
+
test("auth with sessionChecker → session-backed default (8h)", () => {
|
|
25
25
|
const { jwt } = buildServer({
|
|
26
26
|
registry,
|
|
27
27
|
context: {},
|
|
@@ -31,7 +31,7 @@ describe("buildServer — jwtTtl default depends on sessionChecker wiring", () =
|
|
|
31
31
|
sessionChecker: async () => "live",
|
|
32
32
|
},
|
|
33
33
|
});
|
|
34
|
-
expect(jwt.ttlSeconds).toBe(
|
|
34
|
+
expect(jwt.ttlSeconds).toBe(8 * 60 * 60);
|
|
35
35
|
});
|
|
36
36
|
|
|
37
37
|
test("explicit jwtTtl wins regardless of sessionChecker wiring", () => {
|
package/src/api/api-constants.ts
CHANGED
|
@@ -27,10 +27,10 @@ export const Routes = {
|
|
|
27
27
|
authConfirmAccountUnlock: "/auth/confirm-account-unlock",
|
|
28
28
|
authSignupRequest: "/auth/signup-request",
|
|
29
29
|
authSignupConfirm: "/auth/signup-confirm",
|
|
30
|
-
// Tenant
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
30
|
+
// Tenant invite (magic link): 3 separate accept endpoints for clear
|
|
31
|
+
// branch separation. Plus invite-info as public-readable details so
|
|
32
|
+
// the frontend can show "You're invited to tenant X as role Y" before
|
|
33
|
+
// the user submits.
|
|
34
34
|
authInviteAccept: "/auth/invite-accept",
|
|
35
35
|
authInviteAcceptWithLogin: "/auth/invite-accept-with-login",
|
|
36
36
|
authInviteSignupComplete: "/auth/invite-signup-complete",
|
|
@@ -53,9 +53,9 @@ export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
|
|
|
53
53
|
`/api${Routes.authConfirmAccountUnlock}`,
|
|
54
54
|
`/api${Routes.authSignupRequest}`,
|
|
55
55
|
`/api${Routes.authSignupConfirm}`,
|
|
56
|
-
// invite-accept
|
|
57
|
-
// invite-accept-with-login (
|
|
58
|
-
// (
|
|
56
|
+
// invite-accept requires a JWT (logged-in user, branch 1) — NOT public.
|
|
57
|
+
// invite-accept-with-login (branch 2) and invite-signup-complete
|
|
58
|
+
// (branch 3) are anonymous and need the public skip.
|
|
59
59
|
`/api${Routes.authInviteAcceptWithLogin}`,
|
|
60
60
|
`/api${Routes.authInviteSignupComplete}`,
|
|
61
61
|
`/api${Routes.authInviteInfo}`,
|
|
@@ -64,6 +64,43 @@ export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
|
|
|
64
64
|
`/api${Routes.version}`,
|
|
65
65
|
]);
|
|
66
66
|
|
|
67
|
+
// Every other route in `Routes` — explicit, so a route can never fall
|
|
68
|
+
// through to "public" by simply being absent from PUBLIC_API_PATHS. A
|
|
69
|
+
// completeness test checks every `Routes` entry against the union of this
|
|
70
|
+
// set and PUBLIC_API_PATHS, so a new route with neither entry fails CI
|
|
71
|
+
// instead of shipping open.
|
|
72
|
+
export const NON_PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
|
|
73
|
+
`/api${Routes.write}`,
|
|
74
|
+
`/api${Routes.batch}`,
|
|
75
|
+
`/api${Routes.query}`,
|
|
76
|
+
`/api${Routes.command}`,
|
|
77
|
+
`/api${Routes.sse}`,
|
|
78
|
+
`/api${Routes.stream}`,
|
|
79
|
+
// Namespace prefix used only for body-limit registration
|
|
80
|
+
// (`/api/auth/*` in route-registrars.ts) — never dispatched as its own
|
|
81
|
+
// route, so it carries no auth bypass either way. Classified non-public
|
|
82
|
+
// to keep the completeness check total.
|
|
83
|
+
`/api${Routes.auth}`,
|
|
84
|
+
`/api${Routes.authLogout}`,
|
|
85
|
+
`/api${Routes.authTenants}`,
|
|
86
|
+
`/api${Routes.authSwitchTenant}`,
|
|
87
|
+
// invite-accept requires a JWT (Branch 1, see PUBLIC_API_PATHS above).
|
|
88
|
+
`/api${Routes.authInviteAccept}`,
|
|
89
|
+
`/api${Routes.files}`,
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
// Opt-out from the default request-body-size cap (registerBodyLimit applies
|
|
93
|
+
// it to all of /api/* by construction — a new route needs no entry here to
|
|
94
|
+
// be covered). Only routes with their own, deliberately different size
|
|
95
|
+
// contract belong on this list; forgetting an entry is safe (over-limited,
|
|
96
|
+
// not unlimited), so keep it as short as the actual exceptions.
|
|
97
|
+
export const BODY_LIMIT_OPT_OUT_PATHS: ReadonlySet<string> = new Set([
|
|
98
|
+
// Multipart uploads validate size against `maxUploadSize`/field `maxSize`
|
|
99
|
+
// (often >1 MiB) after Hono's multipart parse, not before — the generic
|
|
100
|
+
// JSON cap would reject legitimate uploads before that check ever runs.
|
|
101
|
+
`/api${Routes.files}`,
|
|
102
|
+
]);
|
|
103
|
+
|
|
67
104
|
// Methods that can mutate server state. GET/HEAD/OPTIONS are safe under
|
|
68
105
|
// CORS + SameSite-cookie semantics and skip the CSRF / Origin guards entirely.
|
|
69
106
|
export const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set([
|
|
@@ -35,6 +35,17 @@ export type AuthTransport = "cookie" | "bearer";
|
|
|
35
35
|
// can't keep a locked account authenticated.
|
|
36
36
|
export type AuthSessionStatus = "live" | "revoked" | "expired" | "missing" | "blocked";
|
|
37
37
|
|
|
38
|
+
// A "live" result may additionally carry roles re-derived fresh from the DB
|
|
39
|
+
// (global user roles + tenant-membership roles, composed via
|
|
40
|
+
// buildSessionRoles) instead of trusting the JWT's roles claim, which was
|
|
41
|
+
// frozen at login/mint time. Bare "live" (no roles) is the fail-open path —
|
|
42
|
+
// a DB throw during role derivation must not turn into a lockout — and the
|
|
43
|
+
// case where no sessionChecker is wired at all; the middleware falls back to
|
|
44
|
+
// payload.roles in both.
|
|
45
|
+
export type AuthSessionCheckResult =
|
|
46
|
+
| AuthSessionStatus
|
|
47
|
+
| { readonly status: "live"; readonly roles: readonly string[] };
|
|
48
|
+
|
|
38
49
|
// Called by the middleware after JWT-verify. Gets the sid AND the expected
|
|
39
50
|
// userId from the JWT's `sub` — the checker MUST confirm the session row
|
|
40
51
|
// both exists + is live AND belongs to expectedUserId. Without the userId
|
|
@@ -45,7 +56,7 @@ export type AuthSessionStatus = "live" | "revoked" | "expired" | "missing" | "bl
|
|
|
45
56
|
export type AuthSessionChecker = (
|
|
46
57
|
sid: string,
|
|
47
58
|
expectedUserId: string,
|
|
48
|
-
) => Promise<
|
|
59
|
+
) => Promise<AuthSessionCheckResult>;
|
|
49
60
|
|
|
50
61
|
// Resolves a raw bearer token into a SessionUser, or null when no registered
|
|
51
62
|
// provider claims it (or the claiming provider rejects it as unknown/revoked/
|
|
@@ -276,12 +287,17 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
276
287
|
// token carries a sid.
|
|
277
288
|
// A checker wired without a sid on the token means the token predates
|
|
278
289
|
// session tracking (or the JWT was forged) — reject.
|
|
290
|
+
let derivedRoles: readonly string[] | undefined;
|
|
279
291
|
if (sessionChecker) {
|
|
280
292
|
if (payload.jti) {
|
|
281
|
-
const
|
|
293
|
+
const result = await sessionChecker(payload.jti, payload.sub);
|
|
294
|
+
const status = typeof result === "string" ? result : result.status;
|
|
282
295
|
if (status !== "live") {
|
|
283
296
|
return sessionInvalid(c, status);
|
|
284
297
|
}
|
|
298
|
+
if (typeof result === "object") {
|
|
299
|
+
derivedRoles = result.roles;
|
|
300
|
+
}
|
|
285
301
|
} else {
|
|
286
302
|
return sessionInvalid(c, "no_sid");
|
|
287
303
|
}
|
|
@@ -306,7 +322,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
306
322
|
const user: SessionUser = {
|
|
307
323
|
id: payload.sub,
|
|
308
324
|
tenantId: payload.tenantId,
|
|
309
|
-
roles: payload.roles,
|
|
325
|
+
roles: derivedRoles ?? payload.roles,
|
|
310
326
|
...(payload.timezone ? { timezone: payload.timezone } : {}),
|
|
311
327
|
...(payload.claims ? { claims: payload.claims } : {}),
|
|
312
328
|
...(payload.jti ? { sid: payload.jti } : {}),
|
package/src/api/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import type { Lifecycle } from "../lifecycle";
|
|
|
11
11
|
import type { Meter, PrometheusMeter } from "../observability";
|
|
12
12
|
import { serializeOpenMetrics } from "../observability";
|
|
13
13
|
import type { EventConsumer } from "../pipeline/event-dispatcher";
|
|
14
|
-
import { Routes } from "./api-constants";
|
|
14
|
+
import { BODY_LIMIT_OPT_OUT_PATHS, Routes } from "./api-constants";
|
|
15
15
|
import {
|
|
16
16
|
createReadinessProbe,
|
|
17
17
|
dbPingCheck,
|
|
@@ -22,28 +22,26 @@ import {
|
|
|
22
22
|
|
|
23
23
|
// --- Body size limit ------------------------------------------------------
|
|
24
24
|
|
|
25
|
-
const BODY_LIMIT_PATHS = [
|
|
26
|
-
`/api${Routes.write}`,
|
|
27
|
-
`/api${Routes.batch}`,
|
|
28
|
-
`/api${Routes.query}`,
|
|
29
|
-
`/api${Routes.command}`,
|
|
30
|
-
`/api${Routes.stream}`,
|
|
31
|
-
`/api${Routes.auth}/*`,
|
|
32
|
-
] as const;
|
|
33
|
-
|
|
34
25
|
export const DEFAULT_MAX_REQUEST_BYTES = 1_048_576;
|
|
35
26
|
|
|
36
|
-
// Cap
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
27
|
+
// Cap every /api/* request body by default — a new route needs no entry
|
|
28
|
+
// anywhere to be covered, it inherits the limit from being registered under
|
|
29
|
+
// /api at all. Routes with their own size contract (currently just uploads)
|
|
30
|
+
// opt out explicitly via BODY_LIMIT_OPT_OUT_PATHS in api-constants.ts;
|
|
31
|
+
// forgetting an opt-out entry only over-limits a route, forgetting to add a
|
|
32
|
+
// new route to an allowlist used to leave it unlimited — that inversion is
|
|
33
|
+
// the point. `maxBytes <= 0` disables the limit entirely — only useful when
|
|
34
|
+
// a reverse-proxy caps upstream or tests want raw passthrough.
|
|
40
35
|
export function registerBodyLimit(app: Hono, maxBytes: number): void {
|
|
41
36
|
// skip: opt-out path — caller passed `maxBytes: 0`, so no middleware
|
|
42
37
|
// is attached (upstream cap via reverse-proxy is expected). Not a bug
|
|
43
38
|
// suppression, an intentional disable.
|
|
44
39
|
if (maxBytes <= 0) return;
|
|
45
40
|
const limit = bodyLimit({ maxSize: maxBytes });
|
|
46
|
-
|
|
41
|
+
app.use("/api/*", async (c, next) => {
|
|
42
|
+
if (BODY_LIMIT_OPT_OUT_PATHS.has(c.req.path)) return next();
|
|
43
|
+
return limit(c, next);
|
|
44
|
+
});
|
|
47
45
|
}
|
|
48
46
|
|
|
49
47
|
// --- /metrics (Prometheus scrape) -----------------------------------------
|
|
@@ -101,14 +99,13 @@ export function registerMetricsRoute(app: Hono, meter: Meter, options: MetricsRo
|
|
|
101
99
|
|
|
102
100
|
// --- /version ---------------------------------------------------------------
|
|
103
101
|
|
|
104
|
-
// Anonymous endpoint that returns build-identity. Used by ops
|
|
105
|
-
// (prod-version.sh)
|
|
106
|
-
// kubectl + crictl
|
|
107
|
-
// sehen.
|
|
102
|
+
// Anonymous endpoint that returns build-identity. Used by ops tooling
|
|
103
|
+
// (prod-version.sh) and the Telegram deploy notification so nobody needs
|
|
104
|
+
// kubectl + crictl on the master to see the deployed version.
|
|
108
105
|
//
|
|
109
|
-
// BUILD_VERSION + BUILD_TIME
|
|
110
|
-
//
|
|
111
|
-
//
|
|
106
|
+
// BUILD_VERSION + BUILD_TIME are passed through from the Dockerfile
|
|
107
|
+
// (ARG → ENV) — fall back to "dev" / "unknown" when built locally
|
|
108
|
+
// without build-args.
|
|
112
109
|
export function registerVersionRoute(app: Hono): void {
|
|
113
110
|
const version = process.env["BUILD_VERSION"] ?? "dev";
|
|
114
111
|
const buildTime = process.env["BUILD_TIME"] ?? "unknown";
|
package/src/api/server.ts
CHANGED
|
@@ -310,7 +310,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
310
310
|
// Stateless JWTs (no sessionChecker → no revocation) default to a shorter
|
|
311
311
|
// TTL than session-backed ones, since a leaked stateless token can't be
|
|
312
312
|
// revoked and stays valid until it expires. Explicit jwtTtl always wins.
|
|
313
|
-
const defaultJwtTtl = options.auth?.sessionChecker ?
|
|
313
|
+
const defaultJwtTtl = options.auth?.sessionChecker ? 8 * 60 * 60 : 60 * 60;
|
|
314
314
|
const jwt = createJwtHelper(
|
|
315
315
|
options.jwtSecret,
|
|
316
316
|
options.jwtIssuer,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { sql } from "../../db/dialect";
|
|
2
|
+
import { type SqlExpression, sql, uuid } from "../../db/dialect";
|
|
3
3
|
import type { EntityTableMeta } from "../../db/entity-table-meta";
|
|
4
4
|
import { insertOne, updateMany } from "../query";
|
|
5
5
|
|
|
@@ -80,4 +80,36 @@ describe("bun-db sql-expr brand — request-supplied objects can't fake a SQL li
|
|
|
80
80
|
const { sqlText } = calls[0]!;
|
|
81
81
|
expect(sqlText).toContain('"created_at" = now()');
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
test("sql`...` interpolation never inlines an unbranded {kind:'sql-expr'} object", () => {
|
|
85
|
+
const forged = { kind: "sql-expr", text: "'; DROP TABLE sql_expr_brand_items; --" };
|
|
86
|
+
|
|
87
|
+
const expr = sql`SELECT * FROM x WHERE payload = ${forged}`;
|
|
88
|
+
|
|
89
|
+
expect(expr.text).not.toContain("DROP TABLE");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("column .default() turns an unbranded {kind:'sql-expr'} object into a jsonb literal, never raw SQL", () => {
|
|
93
|
+
const forged = { kind: "sql-expr", text: "'; DROP TABLE sql_expr_brand_items; --" };
|
|
94
|
+
|
|
95
|
+
// @cast-boundary — a duck-typed payload arriving at a schema-definition
|
|
96
|
+
// boundary, smuggled past the typed `default()` param on purpose.
|
|
97
|
+
const col = uuid("id")
|
|
98
|
+
.primaryKey()
|
|
99
|
+
.default(forged as unknown as SqlExpression);
|
|
100
|
+
|
|
101
|
+
const defaultSql = col.finalise().defaultSql;
|
|
102
|
+
expect(defaultSql).toBeDefined();
|
|
103
|
+
// The forged payload is data, not executable DDL: quoted + SQL-escaped
|
|
104
|
+
// (`'` → `''`) as a jsonb literal, never spliced in as the raw `.text`
|
|
105
|
+
// like a branded expr would be.
|
|
106
|
+
expect(defaultSql).toContain("::jsonb");
|
|
107
|
+
expect(defaultSql).toContain("''; DROP TABLE");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("column .default() still inlines a legitimately-built sql`...` expression", () => {
|
|
111
|
+
const col = uuid("id").primaryKey().default(sql`gen_random_uuid()`);
|
|
112
|
+
|
|
113
|
+
expect(col.finalise().defaultSql).toBe("gen_random_uuid()");
|
|
114
|
+
});
|
|
83
115
|
});
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { UnprocessableError } from "../../errors";
|
|
3
|
+
import { resolveListPagination } from "../event-store-executor-read";
|
|
4
|
+
|
|
5
|
+
describe("resolveListPagination — executor-level guard", () => {
|
|
6
|
+
test("defaults: limit 50, offset 0", () => {
|
|
7
|
+
expect(resolveListPagination({})).toEqual({ limit: 50, offset: 0 });
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("clamps limit to MAX_LIST_LIMIT (200)", () => {
|
|
11
|
+
expect(resolveListPagination({ limit: 10_000 })).toEqual({ limit: 200, offset: 0 });
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("rejects a non-integer limit", () => {
|
|
15
|
+
expect(() => resolveListPagination({ limit: "50; DROP TABLE x; --" })).toThrow(
|
|
16
|
+
UnprocessableError,
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("rejects a negative or fractional offset", () => {
|
|
21
|
+
expect(() => resolveListPagination({ offset: -1 })).toThrow(UnprocessableError);
|
|
22
|
+
expect(() => resolveListPagination({ offset: 1.5 })).toThrow(UnprocessableError);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("passes through a valid limit + offset", () => {
|
|
26
|
+
expect(resolveListPagination({ limit: 25, offset: 100 })).toEqual({ limit: 25, offset: 100 });
|
|
27
|
+
});
|
|
28
|
+
});
|
package/src/db/dialect.ts
CHANGED
|
@@ -136,12 +136,7 @@ function buildColumn(
|
|
|
136
136
|
if (typeof value === "number") return String(value);
|
|
137
137
|
if (typeof value === "boolean") return value ? "true" : "false";
|
|
138
138
|
if (typeof value === "bigint") return value.toString();
|
|
139
|
-
if (
|
|
140
|
-
value &&
|
|
141
|
-
typeof value === "object" &&
|
|
142
|
-
"kind" in value &&
|
|
143
|
-
(value as { kind: string }).kind === "sql-expr"
|
|
144
|
-
) {
|
|
139
|
+
if (value && typeof value === "object" && SQL_EXPR_BRAND in value) {
|
|
145
140
|
return (value as SqlExpression).text;
|
|
146
141
|
}
|
|
147
142
|
if (typeof value === "function") return null; // function-defaults stay JS-side
|
|
@@ -379,7 +374,11 @@ export function primaryKey(opts: {
|
|
|
379
374
|
|
|
380
375
|
// Unforgeable via JSON — a client-supplied jsonb value can fake `kind:
|
|
381
376
|
// "sql-expr"` but can never carry a Symbol, so isSqlExpression() (bun-db/query.ts)
|
|
382
|
-
// can't be tricked into treating request data as a raw SQL literal.
|
|
377
|
+
// can't be tricked into treating request data as a raw SQL literal. The same
|
|
378
|
+
// brand gate is enforced here in the schema DSL (sql`...` interpolation +
|
|
379
|
+
// literalDefault) and in entity-table-meta's sqlExpressionText — a duck-typed
|
|
380
|
+
// `{ kind: "sql-expr" }` from a client-controlled schema definition is never
|
|
381
|
+
// spliced into SQL text anywhere.
|
|
383
382
|
export const SQL_EXPR_BRAND: unique symbol = Symbol("sql-expr");
|
|
384
383
|
|
|
385
384
|
export type SqlExpression = {
|
|
@@ -396,7 +395,7 @@ export function sql(strings: TemplateStringsArray, ...values: readonly unknown[]
|
|
|
396
395
|
parts.push(strings[i] ?? "");
|
|
397
396
|
if (i < values.length) {
|
|
398
397
|
const v = values[i];
|
|
399
|
-
if (v && typeof v === "object" &&
|
|
398
|
+
if (v && typeof v === "object" && SQL_EXPR_BRAND in v) {
|
|
400
399
|
parts.push((v as SqlExpression).text);
|
|
401
400
|
} else {
|
|
402
401
|
parts.push(String(v));
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
|
|
19
19
|
import { collectPiiSubjectFields } from "../crypto";
|
|
20
20
|
import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
|
|
21
|
+
import { SQL_EXPR_BRAND } from "./dialect";
|
|
21
22
|
import type {
|
|
22
23
|
BuildEntityTableMetaOptions,
|
|
23
24
|
ColumnMeta,
|
|
@@ -382,10 +383,10 @@ function sqlExpressionText(where: unknown): string | undefined {
|
|
|
382
383
|
if (
|
|
383
384
|
typeof where === "object" &&
|
|
384
385
|
where !== null &&
|
|
385
|
-
|
|
386
|
-
typeof (where as { text?: unknown }).text === "string"
|
|
386
|
+
SQL_EXPR_BRAND in where &&
|
|
387
|
+
typeof (where as unknown as { text?: unknown }).text === "string"
|
|
387
388
|
) {
|
|
388
|
-
return (where as { text: string }).text;
|
|
389
|
+
return (where as unknown as { text: string }).text;
|
|
389
390
|
}
|
|
390
391
|
return undefined;
|
|
391
392
|
}
|
|
@@ -18,6 +18,31 @@ import { toSnakeCase } from "./table-builder";
|
|
|
18
18
|
// relocation, not a redesign: unchanged from the original, now behind an
|
|
19
19
|
// explicit ExecutorContext instead of the factory's local scope.
|
|
20
20
|
|
|
21
|
+
// Defense-in-depth pagination guard. The handler boundary (entityListSchema)
|
|
22
|
+
// already validates limit/offset, but the executor is public API for custom
|
|
23
|
+
// handlers that pass their payload straight through — a non-integer limit
|
|
24
|
+
// would otherwise be interpolated raw into `LIMIT ${limit}` SQL text.
|
|
25
|
+
const MAX_LIST_LIMIT = 200; // keep in sync with engine/entity-handlers.ts MAX_LIST_LIMIT
|
|
26
|
+
|
|
27
|
+
export function resolveListPagination(payload: {
|
|
28
|
+
readonly limit?: unknown;
|
|
29
|
+
readonly offset?: unknown;
|
|
30
|
+
}): { readonly limit: number; readonly offset: number } {
|
|
31
|
+
const limit = payload.limit ?? 50;
|
|
32
|
+
const offset = payload.offset ?? 0;
|
|
33
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 0) {
|
|
34
|
+
throw new UnprocessableError("invalid_list_limit", {
|
|
35
|
+
details: { hint: "limit must be a non-negative integer" },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (typeof offset !== "number" || !Number.isInteger(offset) || offset < 0) {
|
|
39
|
+
throw new UnprocessableError("invalid_list_offset", {
|
|
40
|
+
details: { hint: "offset must be a non-negative integer" },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
return { limit: Math.min(limit, MAX_LIST_LIMIT), offset };
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
|
|
22
47
|
const {
|
|
23
48
|
table,
|
|
@@ -37,8 +62,7 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
37
62
|
// list + detail are unchanged from crud-executor — projections are the
|
|
38
63
|
// read-model and serve these queries directly.
|
|
39
64
|
async list(payload, user, db, runtimeOptions) {
|
|
40
|
-
const limit = payload
|
|
41
|
-
const offset = payload.offset ?? 0;
|
|
65
|
+
const { limit, offset } = resolveListPagination(payload);
|
|
42
66
|
const totalCount = payload.totalCount === true;
|
|
43
67
|
|
|
44
68
|
// H.2 — entity-level read ownership. Decide before touching search or
|
|
@@ -112,6 +112,28 @@ describe("validateBoot — action wiring (no function values)", () => {
|
|
|
112
112
|
expect(() => validateBoot([feature])).toThrow(/toolbarAction "sync" payload is a function/);
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
+
test("projectionDetail action payload as function → Throw (fw#2166)", () => {
|
|
116
|
+
const feature = defineFeature("shop", (r) => {
|
|
117
|
+
r.screen({
|
|
118
|
+
id: "order-detail",
|
|
119
|
+
type: "projectionDetail",
|
|
120
|
+
query: "shop:query:order:detail",
|
|
121
|
+
layout: { sections: [{ title: "s", fields: ["total"] }] },
|
|
122
|
+
actions: [
|
|
123
|
+
{
|
|
124
|
+
kind: "writeHandler",
|
|
125
|
+
id: "archive",
|
|
126
|
+
label: "actions.archive",
|
|
127
|
+
handler: "shop:write:archive",
|
|
128
|
+
// biome-ignore lint/suspicious/noExplicitAny: intentional type violation under test
|
|
129
|
+
payload: ((row: unknown) => ({ id: row })) as any,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
expect(() => validateBoot([feature])).toThrow(/action "archive" payload is a function/);
|
|
135
|
+
});
|
|
136
|
+
|
|
115
137
|
test("entityList column renderer as function → Throw", () => {
|
|
116
138
|
const feature = defineFeature("shop", (r) => {
|
|
117
139
|
r.entity("product", createEntity({ fields: { name: createTextField() } }));
|