@cosmicdrift/kumiko-framework 0.159.1 → 0.161.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 +3 -3
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/api-constants.ts +1 -0
- package/src/api/auth-middleware.ts +17 -44
- package/src/api/auth-routes.ts +6 -2
- package/src/api/index.ts +1 -0
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +5 -4
- package/src/bun-db/query.ts +12 -25
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/entity-table-meta-types.ts +2 -92
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/define-feature.ts +1 -0
- package/src/engine/define-handler.ts +1 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-entity-handlers.ts +35 -1
- package/src/engine/index.ts +3 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -94
- package/src/engine/types/entity-handlers.ts +2 -30
- package/src/engine/types/feature.ts +2 -1021
- package/src/engine/types/fields.ts +2 -685
- package/src/engine/types/handlers.ts +2 -820
- package/src/engine/types/hooks.ts +2 -170
- package/src/engine/types/index.ts +44 -36
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -83
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/screen.ts +2 -747
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/workspace.ts +2 -42
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +2 -21
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -22
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +39 -1
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/rate-limit/resolver.ts +10 -30
- package/src/secrets/envelope-cipher.ts +4 -6
- package/src/secrets/types.ts +2 -177
- package/src/stack/request-helper.ts +19 -2
- package/src/stack/test-stack.ts +33 -14
- package/src/time/tz-context.ts +9 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.161.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>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.161.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.18",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.161.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
|
@@ -28,6 +28,17 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
28
28
|
async () => [{ id: 1, name: "Test" }],
|
|
29
29
|
{ access: { openToAll: true } },
|
|
30
30
|
);
|
|
31
|
+
|
|
32
|
+
r.streamHandler(
|
|
33
|
+
"item:tail",
|
|
34
|
+
z.object({ count: z.number().int().min(0) }),
|
|
35
|
+
async function* (query) {
|
|
36
|
+
for (let i = 0; i < query.payload.count; i++) {
|
|
37
|
+
yield { i };
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
{ access: { roles: ["Admin"] } },
|
|
41
|
+
);
|
|
31
42
|
});
|
|
32
43
|
|
|
33
44
|
const registry = createRegistry([testFeature]);
|
|
@@ -215,6 +226,60 @@ describe("GET /api/sse", () => {
|
|
|
215
226
|
});
|
|
216
227
|
});
|
|
217
228
|
|
|
229
|
+
// --- Stream (dispatcher-driven SSE) ---
|
|
230
|
+
|
|
231
|
+
function parseSseFrames(text: string): Array<{ event: string; data: string }> {
|
|
232
|
+
return text
|
|
233
|
+
.split("\n\n")
|
|
234
|
+
.filter((frame) => frame.trim().length > 0)
|
|
235
|
+
.map((frame) => {
|
|
236
|
+
const event = /^event: (.*)$/m.exec(frame)?.[1] ?? "";
|
|
237
|
+
const data = /^data: (.*)$/m.exec(frame)?.[1] ?? "";
|
|
238
|
+
return { event, data };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
describe("POST /api/stream", () => {
|
|
243
|
+
test("dispatches stream handler and yields chunk frames then done", async () => {
|
|
244
|
+
const headers = await authHeader(adminUser);
|
|
245
|
+
const res = await req(
|
|
246
|
+
"POST",
|
|
247
|
+
"/api/stream",
|
|
248
|
+
{ type: "test:stream:item:tail", payload: { count: 3 } },
|
|
249
|
+
headers,
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(res.status).toBe(200);
|
|
253
|
+
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
|
254
|
+
const frames = parseSseFrames(await res.text());
|
|
255
|
+
expect(frames).toEqual([
|
|
256
|
+
{ event: "chunk", data: JSON.stringify({ i: 0 }) },
|
|
257
|
+
{ event: "chunk", data: JSON.stringify({ i: 1 }) },
|
|
258
|
+
{ event: "chunk", data: JSON.stringify({ i: 2 }) },
|
|
259
|
+
{ event: "done", data: "" },
|
|
260
|
+
]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("access-denied gate surfaces as an error frame, not an HTTP error status", async () => {
|
|
264
|
+
// Dispatch gates (feature/rate-limit/access/validation) fire on the
|
|
265
|
+
// generator's first pull, which happens after SSE headers are already
|
|
266
|
+
// flushed — so an access-denied mid-stream stays HTTP 200.
|
|
267
|
+
const headers = await authHeader(guestUser);
|
|
268
|
+
const res = await req(
|
|
269
|
+
"POST",
|
|
270
|
+
"/api/stream",
|
|
271
|
+
{ type: "test:stream:item:tail", payload: { count: 1 } },
|
|
272
|
+
headers,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
expect(res.status).toBe(200);
|
|
276
|
+
const frames = parseSseFrames(await res.text());
|
|
277
|
+
expect(frames).toHaveLength(1);
|
|
278
|
+
expect(frames[0]?.event).toBe("error");
|
|
279
|
+
expect(JSON.parse(frames[0]?.data ?? "{}")).toMatchObject({ code: "access_denied" });
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
218
283
|
// --- r.httpRoute (feature-deklarierte HTTP-Routes außerhalb /api/) ---
|
|
219
284
|
|
|
220
285
|
describe("feature-declared HTTP routes (r.httpRoute)", () => {
|
|
@@ -37,6 +37,7 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
37
37
|
async query(): Promise<unknown> {
|
|
38
38
|
return [];
|
|
39
39
|
},
|
|
40
|
+
async *stream(): AsyncGenerator<unknown> {},
|
|
40
41
|
async command(): Promise<void> {},
|
|
41
42
|
async batch(): Promise<BatchResult> {
|
|
42
43
|
const ok: BatchResult = { isSuccess: true, results: [] };
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// auth-routes invalid_body + invite-accept success paths — HTTP-layer only,
|
|
2
|
+
// stub Dispatcher (same pattern as auth-routes-mfa-verify.test.ts).
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "bun:test";
|
|
5
|
+
import type { Hono } from "hono";
|
|
6
|
+
import { Hono as HonoCtor } from "hono";
|
|
7
|
+
import type { SessionUser, TenantId } from "../../engine/types";
|
|
8
|
+
import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
|
|
9
|
+
import { TestUsers } from "../../stack";
|
|
10
|
+
import { getSetCookies } from "../../testing/http-cookies";
|
|
11
|
+
import { PUBLIC_API_PATHS } from "../api-constants";
|
|
12
|
+
import { AUTH_COOKIE_NAME, authMiddleware, CSRF_COOKIE_NAME } from "../auth-middleware";
|
|
13
|
+
import { type AuthRoutesConfig, createAuthRoutes } from "../auth-routes";
|
|
14
|
+
import { createJwtHelper } from "../jwt";
|
|
15
|
+
|
|
16
|
+
const JWT_SECRET = "auth-routes-invalid-body-invite-secret-min-32-chars";
|
|
17
|
+
const INVITE_ACCEPT_QN = "auth:write:invite-accept";
|
|
18
|
+
const INVITE_LOGIN_QN = "auth:write:invite-accept-with-login";
|
|
19
|
+
|
|
20
|
+
function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
21
|
+
const base: Dispatcher = {
|
|
22
|
+
async write(): Promise<WriteResult> {
|
|
23
|
+
const ok: WriteResult = { isSuccess: true, data: { kind: "noop" } };
|
|
24
|
+
return ok;
|
|
25
|
+
},
|
|
26
|
+
async query(): Promise<unknown> {
|
|
27
|
+
return [];
|
|
28
|
+
},
|
|
29
|
+
async command(): Promise<void> {},
|
|
30
|
+
async batch(): Promise<BatchResult> {
|
|
31
|
+
return { isSuccess: true, results: [] };
|
|
32
|
+
},
|
|
33
|
+
async resolveAuthClaims(): Promise<Record<string, unknown>> {
|
|
34
|
+
return {};
|
|
35
|
+
},
|
|
36
|
+
// Stream API added with r.streamHandler (#1446) — stub unused in these routes.
|
|
37
|
+
async *stream(): AsyncGenerator<unknown> {},
|
|
38
|
+
};
|
|
39
|
+
return { ...base, ...overrides };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function buildApp(
|
|
43
|
+
overrides: Partial<AuthRoutesConfig> = {},
|
|
44
|
+
dispatcher: Dispatcher = createStubDispatcher(),
|
|
45
|
+
): Promise<{ app: Hono; validToken: string }> {
|
|
46
|
+
const jwt = createJwtHelper(JWT_SECRET);
|
|
47
|
+
const validToken = await jwt.sign(TestUsers.user);
|
|
48
|
+
const config: AuthRoutesConfig = {
|
|
49
|
+
membershipQuery: "tenant:query:memberships",
|
|
50
|
+
loginHandler: "auth:write:login",
|
|
51
|
+
loginRateLimit: null,
|
|
52
|
+
...overrides,
|
|
53
|
+
};
|
|
54
|
+
const app = new HonoCtor();
|
|
55
|
+
const jwtGuard = authMiddleware(jwt);
|
|
56
|
+
app.use("/api/*", async (c, next) => {
|
|
57
|
+
if (PUBLIC_API_PATHS.has(c.req.path)) return next();
|
|
58
|
+
return jwtGuard(c, next);
|
|
59
|
+
});
|
|
60
|
+
app.route("/api", createAuthRoutes(dispatcher, jwt, config));
|
|
61
|
+
return { app, validToken };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const inviteConfig: AuthRoutesConfig["invite"] = {
|
|
65
|
+
acceptHandler: INVITE_ACCEPT_QN,
|
|
66
|
+
acceptWithLoginHandler: INVITE_LOGIN_QN,
|
|
67
|
+
signupCompleteHandler: "auth:write:invite-signup-complete",
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
describe("POST /auth/login — invalid_body", () => {
|
|
71
|
+
test("400 when password field is missing", async () => {
|
|
72
|
+
let dispatched = false;
|
|
73
|
+
const dispatcher = createStubDispatcher({
|
|
74
|
+
async write(): Promise<WriteResult> {
|
|
75
|
+
dispatched = true;
|
|
76
|
+
return { isSuccess: true, data: { kind: "auth-session", session: TestUsers.user } };
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const { app } = await buildApp({}, dispatcher);
|
|
80
|
+
const res = await app.request("/api/auth/login", {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "Content-Type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ email: "a@b.c" }),
|
|
84
|
+
});
|
|
85
|
+
expect(res.status).toBe(400);
|
|
86
|
+
const body = (await res.json()) as { isSuccess: boolean; error: string };
|
|
87
|
+
expect(body.error).toBe("invalid_body");
|
|
88
|
+
expect(dispatched).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("POST /auth/invite-accept", () => {
|
|
93
|
+
test("requires JWT — not a public route", async () => {
|
|
94
|
+
expect(PUBLIC_API_PATHS.has("/api/auth/invite-accept")).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("400 invalid_body before dispatch", async () => {
|
|
98
|
+
let dispatched = false;
|
|
99
|
+
const dispatcher = createStubDispatcher({
|
|
100
|
+
async write(): Promise<WriteResult> {
|
|
101
|
+
dispatched = true;
|
|
102
|
+
return {
|
|
103
|
+
isSuccess: true,
|
|
104
|
+
data: {
|
|
105
|
+
kind: "invite-accepted",
|
|
106
|
+
tenantId: TestUsers.otherTenant.tenantId,
|
|
107
|
+
role: "User",
|
|
108
|
+
alreadyMember: false,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const { app, validToken } = await buildApp({ invite: inviteConfig }, dispatcher);
|
|
114
|
+
const res = await app.request("/api/auth/invite-accept", {
|
|
115
|
+
method: "POST",
|
|
116
|
+
headers: {
|
|
117
|
+
"Content-Type": "application/json",
|
|
118
|
+
Authorization: `Bearer ${validToken}`,
|
|
119
|
+
},
|
|
120
|
+
body: JSON.stringify({}),
|
|
121
|
+
});
|
|
122
|
+
expect(res.status).toBe(400);
|
|
123
|
+
expect((await res.json()) as { isSuccess: boolean; error: string }).toMatchObject({
|
|
124
|
+
isSuccess: false,
|
|
125
|
+
error: "invalid_body",
|
|
126
|
+
});
|
|
127
|
+
expect(dispatched).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("success returns tenantId, role, alreadyMember", async () => {
|
|
131
|
+
const tenantId = TestUsers.otherTenant.tenantId as TenantId;
|
|
132
|
+
let receivedUser: SessionUser | undefined;
|
|
133
|
+
const dispatcher = createStubDispatcher({
|
|
134
|
+
async write(qn, payload, user): Promise<WriteResult> {
|
|
135
|
+
expect(qn).toBe(INVITE_ACCEPT_QN);
|
|
136
|
+
expect(payload).toEqual({ token: "invite-token-abc" });
|
|
137
|
+
receivedUser = user;
|
|
138
|
+
return {
|
|
139
|
+
isSuccess: true,
|
|
140
|
+
data: {
|
|
141
|
+
kind: "invite-accepted",
|
|
142
|
+
tenantId,
|
|
143
|
+
role: "Editor",
|
|
144
|
+
alreadyMember: true,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
const { app, validToken } = await buildApp({ invite: inviteConfig }, dispatcher);
|
|
150
|
+
const res = await app.request("/api/auth/invite-accept", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: {
|
|
153
|
+
"Content-Type": "application/json",
|
|
154
|
+
Authorization: `Bearer ${validToken}`,
|
|
155
|
+
},
|
|
156
|
+
body: JSON.stringify({ token: "invite-token-abc" }),
|
|
157
|
+
});
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
expect(receivedUser?.id).toBe(TestUsers.user.id);
|
|
160
|
+
expect(await res.json()).toEqual({
|
|
161
|
+
isSuccess: true,
|
|
162
|
+
tenantId,
|
|
163
|
+
role: "Editor",
|
|
164
|
+
alreadyMember: true,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("POST /auth/invite-accept-with-login — invalid_body + success", () => {
|
|
170
|
+
test("400 when email missing", async () => {
|
|
171
|
+
let dispatched = false;
|
|
172
|
+
const dispatcher = createStubDispatcher({
|
|
173
|
+
async write(): Promise<WriteResult> {
|
|
174
|
+
dispatched = true;
|
|
175
|
+
return { isSuccess: true, data: { kind: "auth-session", session: TestUsers.user } };
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
const { app } = await buildApp({ invite: inviteConfig }, dispatcher);
|
|
179
|
+
const res = await app.request("/api/auth/invite-accept-with-login", {
|
|
180
|
+
method: "POST",
|
|
181
|
+
headers: { "Content-Type": "application/json" },
|
|
182
|
+
body: JSON.stringify({ token: "t", password: "long-enough-pw" }),
|
|
183
|
+
});
|
|
184
|
+
expect(res.status).toBe(400);
|
|
185
|
+
expect((await res.json()) as { error: string }).toMatchObject({
|
|
186
|
+
isSuccess: false,
|
|
187
|
+
error: "invalid_body",
|
|
188
|
+
});
|
|
189
|
+
expect(dispatched).toBe(false);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("success mints JWT + cookies", async () => {
|
|
193
|
+
const tenantId = TestUsers.otherTenant.tenantId as TenantId;
|
|
194
|
+
const dispatcher = createStubDispatcher({
|
|
195
|
+
async write(qn, payload): Promise<WriteResult> {
|
|
196
|
+
expect(qn).toBe(INVITE_LOGIN_QN);
|
|
197
|
+
expect(payload).toEqual({
|
|
198
|
+
token: "invite-t",
|
|
199
|
+
email: "user@example.com",
|
|
200
|
+
password: "password123",
|
|
201
|
+
});
|
|
202
|
+
return {
|
|
203
|
+
isSuccess: true,
|
|
204
|
+
data: {
|
|
205
|
+
kind: "auth-session",
|
|
206
|
+
session: TestUsers.user,
|
|
207
|
+
tenantId,
|
|
208
|
+
role: "User",
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
const { app } = await buildApp({ invite: inviteConfig }, dispatcher);
|
|
214
|
+
const res = await app.request("/api/auth/invite-accept-with-login", {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: { "Content-Type": "application/json" },
|
|
217
|
+
body: JSON.stringify({
|
|
218
|
+
token: "invite-t",
|
|
219
|
+
email: "user@example.com",
|
|
220
|
+
password: "password123",
|
|
221
|
+
}),
|
|
222
|
+
});
|
|
223
|
+
expect(res.status).toBe(200);
|
|
224
|
+
const body = (await res.json()) as {
|
|
225
|
+
isSuccess: boolean;
|
|
226
|
+
token: string;
|
|
227
|
+
tenantId: TenantId;
|
|
228
|
+
role: string;
|
|
229
|
+
};
|
|
230
|
+
expect(body.isSuccess).toBe(true);
|
|
231
|
+
expect(typeof body.token).toBe("string");
|
|
232
|
+
expect(body.tenantId).toBe(tenantId);
|
|
233
|
+
expect(body.role).toBe("User");
|
|
234
|
+
expect(getSetCookies(res).get(AUTH_COOKIE_NAME)).toBeDefined();
|
|
235
|
+
expect(getSetCookies(res).get(CSRF_COOKIE_NAME)).toBeDefined();
|
|
236
|
+
});
|
|
237
|
+
});
|
|
@@ -36,6 +36,7 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
36
36
|
async query(): Promise<unknown> {
|
|
37
37
|
return [];
|
|
38
38
|
},
|
|
39
|
+
async *stream(): AsyncGenerator<unknown> {},
|
|
39
40
|
async command(): Promise<void> {},
|
|
40
41
|
async batch(): Promise<BatchResult> {
|
|
41
42
|
const ok: BatchResult = { isSuccess: true, results: [] };
|
|
@@ -48,6 +48,17 @@ const itemFeature = defineFeature("dlive", (r) => {
|
|
|
48
48
|
},
|
|
49
49
|
{ access: { roles: ["Admin"] } },
|
|
50
50
|
);
|
|
51
|
+
|
|
52
|
+
r.streamHandler(
|
|
53
|
+
"item:tail",
|
|
54
|
+
z.object({ count: z.number().int().min(0) }),
|
|
55
|
+
async function* (query) {
|
|
56
|
+
for (let i = 0; i < query.payload.count; i++) {
|
|
57
|
+
yield { i, name: `n-${i}` };
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
{ access: { roles: ["Admin"] } },
|
|
61
|
+
);
|
|
51
62
|
});
|
|
52
63
|
|
|
53
64
|
let stack: TestStack;
|
|
@@ -215,4 +226,67 @@ describe("dispatcher-live (integration) — full path against Kumiko server", ()
|
|
|
215
226
|
const rows = await selectMany(stack.db, itemTable);
|
|
216
227
|
expect(rows).toHaveLength(0);
|
|
217
228
|
});
|
|
229
|
+
|
|
230
|
+
test("stream: yields every chunk then completes (live dispatcher ↔ POST /api/stream)", async () => {
|
|
231
|
+
const { fetch, csrfToken } = await buildFetch();
|
|
232
|
+
const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
|
|
233
|
+
|
|
234
|
+
const chunks: Array<{ i: number; name: string }> = [];
|
|
235
|
+
for await (const chunk of dispatcher.stream<{ i: number; name: string }>(
|
|
236
|
+
"dlive:stream:item:tail",
|
|
237
|
+
{ count: 3 },
|
|
238
|
+
)) {
|
|
239
|
+
chunks.push(chunk);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
expect(chunks).toEqual([
|
|
243
|
+
{ i: 0, name: "n-0" },
|
|
244
|
+
{ i: 1, name: "n-1" },
|
|
245
|
+
{ i: 2, name: "n-2" },
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("stream: access-denied mid-stream throws mapped DispatcherError (HTTP still 200 SSE)", async () => {
|
|
250
|
+
// Non-Admin JWT — stream handler requires Admin. Gate fires after SSE
|
|
251
|
+
// headers are flushed, so the client sees an error frame, not HTTP 403.
|
|
252
|
+
const csrfToken = generateToken();
|
|
253
|
+
const guestJwt = await stack.jwt.sign(TestUsers.user);
|
|
254
|
+
const cookieHeader = `kumiko_auth=${guestJwt}; kumiko_csrf=${csrfToken}`;
|
|
255
|
+
const fetchImpl = (async (url: unknown, init: RequestInit | undefined) => {
|
|
256
|
+
return stack.app.request(String(url), {
|
|
257
|
+
...(init ?? {}),
|
|
258
|
+
headers: { ...(init?.headers ?? {}), Cookie: cookieHeader },
|
|
259
|
+
});
|
|
260
|
+
}) as unknown as typeof fetch;
|
|
261
|
+
const dispatcher = createLiveDispatcher({ fetch: fetchImpl, readCsrf: () => csrfToken });
|
|
262
|
+
|
|
263
|
+
let thrown: unknown;
|
|
264
|
+
try {
|
|
265
|
+
for await (const _ of dispatcher.stream("dlive:stream:item:tail", { count: 1 })) {
|
|
266
|
+
// should not yield
|
|
267
|
+
}
|
|
268
|
+
} catch (e) {
|
|
269
|
+
thrown = e;
|
|
270
|
+
}
|
|
271
|
+
expect(thrown).toMatchObject({ code: "access_denied" });
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("stream: validation failure throws mapped DispatcherError from error frame", async () => {
|
|
275
|
+
const { fetch, csrfToken } = await buildFetch();
|
|
276
|
+
const dispatcher = createLiveDispatcher({ fetch, readCsrf: () => csrfToken });
|
|
277
|
+
|
|
278
|
+
let thrown: unknown;
|
|
279
|
+
try {
|
|
280
|
+
for await (const _ of dispatcher.stream("dlive:stream:item:tail", { count: -1 })) {
|
|
281
|
+
// should not yield
|
|
282
|
+
}
|
|
283
|
+
} catch (e) {
|
|
284
|
+
thrown = e;
|
|
285
|
+
}
|
|
286
|
+
expect(thrown).toMatchObject({ code: "validation_error" });
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
// AbortSignal mid-stream against Hono's in-memory `app.request` is
|
|
290
|
+
// unreliable (body often fully buffered before abort lands). Abort
|
|
291
|
+
// mapping for stream is covered in dispatcher-live unit tests.
|
|
218
292
|
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createInMemoryLoginRateLimiter } from "../auth-routes";
|
|
3
|
+
|
|
4
|
+
describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
|
|
5
|
+
test("sweepExpired drops windows that already reset before accepting a new key", async () => {
|
|
6
|
+
// Tiny thresholds so the Map hits the sweep path without flooding.
|
|
7
|
+
const limiter = createInMemoryLoginRateLimiter(10, 50, {
|
|
8
|
+
maxEntries: 100,
|
|
9
|
+
sweepThreshold: 2,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
expect(await limiter.check("a")).toBe(true);
|
|
13
|
+
expect(await limiter.check("b")).toBe(true);
|
|
14
|
+
// Wait for both windows to expire, then a third check must sweep a+b
|
|
15
|
+
// (hits.size >= sweepThreshold) before inserting "c".
|
|
16
|
+
await Bun.sleep(60);
|
|
17
|
+
expect(await limiter.check("c")).toBe(true);
|
|
18
|
+
// Fresh window for "a" after sweep — not rate-limited.
|
|
19
|
+
expect(await limiter.check("a")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("enforceCap drops oldest entries when the map exceeds maxEntries", async () => {
|
|
23
|
+
const limiter = createInMemoryLoginRateLimiter(100, 60_000, {
|
|
24
|
+
maxEntries: 3,
|
|
25
|
+
sweepThreshold: 10_000, // never sweep — only the hard cap matters
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
expect(await limiter.check("k1")).toBe(true);
|
|
29
|
+
expect(await limiter.check("k2")).toBe(true);
|
|
30
|
+
expect(await limiter.check("k3")).toBe(true);
|
|
31
|
+
// 4th insert trips enforceCap → drops oldest (k1).
|
|
32
|
+
expect(await limiter.check("k4")).toBe(true);
|
|
33
|
+
|
|
34
|
+
// k1 was dropped — a fresh check starts a new window (allowed).
|
|
35
|
+
expect(await limiter.check("k1")).toBe(true);
|
|
36
|
+
// k2/k3/k4 still live in the map (cap=3 after k1 drop + k1 reinsert may
|
|
37
|
+
// drop another). Reset proves the API still works for survivors.
|
|
38
|
+
await limiter.reset("k4");
|
|
39
|
+
expect(await limiter.check("k4")).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// buildServer boot-time guards + httpRoute verb wiring (PUT branch).
|
|
2
|
+
|
|
3
|
+
import { describe, expect, test } from "bun:test";
|
|
4
|
+
import {
|
|
5
|
+
createEntity,
|
|
6
|
+
createFileField,
|
|
7
|
+
createRegistry,
|
|
8
|
+
createTextField,
|
|
9
|
+
defineFeature,
|
|
10
|
+
} from "../../engine";
|
|
11
|
+
import { buildServer } from "../server";
|
|
12
|
+
|
|
13
|
+
const JWT_SECRET = "server-boot-guards-test-secret-min-32-chars";
|
|
14
|
+
|
|
15
|
+
describe("buildServer — file-storage provider guard", () => {
|
|
16
|
+
const fileFieldFeature = defineFeature("needs-files", (r) => {
|
|
17
|
+
r.entity(
|
|
18
|
+
"doc",
|
|
19
|
+
createEntity({
|
|
20
|
+
table: "boot_guard_docs",
|
|
21
|
+
fields: { title: createTextField(), attachment: createFileField() },
|
|
22
|
+
}),
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("throws when registry declares file fields but no provider is mounted", () => {
|
|
27
|
+
expect(() =>
|
|
28
|
+
buildServer({
|
|
29
|
+
registry: createRegistry([fileFieldFeature]),
|
|
30
|
+
context: {},
|
|
31
|
+
jwtSecret: JWT_SECRET,
|
|
32
|
+
}),
|
|
33
|
+
).toThrow(/no file-storage provider is mounted/);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("buildServer — rateLimit resolver guard", () => {
|
|
38
|
+
test("throws when L1 global middleware requested without resolver", () => {
|
|
39
|
+
expect(() =>
|
|
40
|
+
buildServer({
|
|
41
|
+
registry: createRegistry([]),
|
|
42
|
+
context: {},
|
|
43
|
+
jwtSecret: JWT_SECRET,
|
|
44
|
+
rateLimit: { global: { limit: 100, windowSeconds: 60 } },
|
|
45
|
+
}),
|
|
46
|
+
).toThrow(/rateLimit middleware requested but no resolver available/);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("buildServer — feature httpRoute PUT mounting", () => {
|
|
51
|
+
const putFeature = defineFeature("put-route", (r) => {
|
|
52
|
+
r.httpRoute({
|
|
53
|
+
method: "PUT",
|
|
54
|
+
path: "/resource/42",
|
|
55
|
+
anonymous: true,
|
|
56
|
+
handler: (c) => c.json({ method: "PUT", ok: true }),
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const { app } = buildServer({
|
|
61
|
+
registry: createRegistry([putFeature]),
|
|
62
|
+
context: {},
|
|
63
|
+
jwtSecret: JWT_SECRET,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("PUT /resource/42 reaches the declared handler", async () => {
|
|
67
|
+
const res = await app.request("/resource/42", { method: "PUT" });
|
|
68
|
+
expect(res.status).toBe(200);
|
|
69
|
+
expect(await res.json()).toEqual({ method: "PUT", ok: true });
|
|
70
|
+
});
|
|
71
|
+
});
|
package/src/api/api-constants.ts
CHANGED
|
@@ -75,7 +75,7 @@ export type AuthMiddlewareOptions = {
|
|
|
75
75
|
// callers instead of being rejected with 401. The middleware synthesises
|
|
76
76
|
// a SessionUser with id="anonymous" and roles=["anonymous"], scoped to a
|
|
77
77
|
// tenantId resolved through the chain documented on AnonymousAccessConfig.
|
|
78
|
-
readonly anonymousAccess?:
|
|
78
|
+
readonly anonymousAccess?: AnonymousAccessResolved;
|
|
79
79
|
// Consulted after tenantId is resolved (JWT/PAT/anonymous). Returns 410
|
|
80
80
|
// when the tenant is in teardown (destroyRequested/destroying/destroyed).
|
|
81
81
|
// cancel-destruction is exempt while status=destroyRequested.
|
|
@@ -96,50 +96,23 @@ export type TenantResolver = (c: Context) => Promise<TenantId | null> | TenantId
|
|
|
96
96
|
// deployments where a caller could otherwise probe arbitrary ids.
|
|
97
97
|
export type TenantExists = (tenantId: TenantId) => Promise<boolean> | boolean;
|
|
98
98
|
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
// the caller is responsible (see sample for the pattern).
|
|
107
|
-
// Per-request existence check for header/cookie/resolver-supplied ids.
|
|
108
|
-
// Skipped for the defaultTenantId path (the caller already vetted that
|
|
109
|
-
// value when configuring the server).
|
|
110
|
-
type AnonymousAccessConfigCommon = {
|
|
99
|
+
// App-facing anonymous-access config (#1374). tenantResolver / tenantExists
|
|
100
|
+
// / resolverTrust come from auth-foundation providers (EXT_TENANT_RESOLVER /
|
|
101
|
+
// EXT_TENANT_EXISTENCE), resolved at boot into AnonymousAccessResolved.
|
|
102
|
+
//
|
|
103
|
+
// Single-tenant shortcut: when defaultTenantId is set, the server runs in
|
|
104
|
+
// **locked** mode (client must agree or get tenant_mismatch).
|
|
105
|
+
export type AnonymousAccessConfig = {
|
|
111
106
|
readonly defaultTenantId?: TenantId;
|
|
112
|
-
readonly tenantExists?: TenantExists;
|
|
113
107
|
};
|
|
114
108
|
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
// override the resolver, and it is never used as a substitute answer
|
|
123
|
-
// when the resolver returns null either (that would just reopen the
|
|
124
|
-
// same override via an unrecognised host). Pick this whenever the
|
|
125
|
-
// resolver derives the tenant from something the caller cannot control
|
|
126
|
-
// (subdomain, mTLS cert, etc.) — the whole point of such a resolver is
|
|
127
|
-
// defeated if a client header can still override it.
|
|
128
|
-
// - "fallback-only": a client-supplied header/cookie wins outright; the
|
|
129
|
-
// resolver only runs when neither is present. Pick this only when the
|
|
130
|
-
// resolver is a pure convenience fallback for callers that never send
|
|
131
|
-
// a tenant of their own (e.g. a bare API host with no per-tenant
|
|
132
|
-
// subdomains) and its answer carries no more trust than the client's
|
|
133
|
-
// own claim.
|
|
134
|
-
export type AnonymousAccessConfig =
|
|
135
|
-
| (AnonymousAccessConfigCommon & {
|
|
136
|
-
readonly tenantResolver?: undefined;
|
|
137
|
-
readonly resolverTrust?: undefined;
|
|
138
|
-
})
|
|
139
|
-
| (AnonymousAccessConfigCommon & {
|
|
140
|
-
readonly tenantResolver: TenantResolver;
|
|
141
|
-
readonly resolverTrust: "authoritative" | "fallback-only";
|
|
142
|
-
});
|
|
109
|
+
// Runtime config consumed by authMiddleware after boot merges registry
|
|
110
|
+
// providers onto the app-facing AnonymousAccessConfig.
|
|
111
|
+
export type AnonymousAccessResolved = AnonymousAccessConfig & {
|
|
112
|
+
readonly tenantResolver?: TenantResolver;
|
|
113
|
+
readonly tenantExists?: TenantExists;
|
|
114
|
+
readonly resolverTrust?: "authoritative" | "fallback-only";
|
|
115
|
+
};
|
|
143
116
|
|
|
144
117
|
// Where the candidate tenant came from. Drives the validation policy:
|
|
145
118
|
// - header / cookie / resolver: untrusted, must pass tenantExists if set.
|
|
@@ -409,7 +382,7 @@ async function handleVerifiedBearerUser(
|
|
|
409
382
|
// no CSRF vector to defend against).
|
|
410
383
|
async function handleAnonymous(
|
|
411
384
|
c: Context,
|
|
412
|
-
config:
|
|
385
|
+
config: AnonymousAccessResolved,
|
|
413
386
|
next: Next,
|
|
414
387
|
resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
|
|
415
388
|
): Promise<Response | undefined> {
|
|
@@ -495,7 +468,7 @@ type ResolveError = { error: RejectArgs };
|
|
|
495
468
|
// clients that think they're talking to a different installation.
|
|
496
469
|
async function resolveTenant(
|
|
497
470
|
c: Context,
|
|
498
|
-
config:
|
|
471
|
+
config: AnonymousAccessResolved,
|
|
499
472
|
clientTenant: { id: TenantId; source: "header" | "cookie" } | null,
|
|
500
473
|
): Promise<ResolvedTenant | ResolveError> {
|
|
501
474
|
if (config.defaultTenantId !== undefined) {
|