@cosmicdrift/kumiko-framework 0.159.1 → 0.160.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/routes.ts +57 -0
- 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/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.160.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.160.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.160.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
package/src/api/routes.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
|
+
import { streamSSE } from "hono/streaming";
|
|
2
3
|
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
3
4
|
import type { SessionUser } from "../engine/types/handlers";
|
|
4
5
|
import {
|
|
@@ -16,6 +17,7 @@ import { Routes } from "./api-constants";
|
|
|
16
17
|
import { getUser } from "./auth-middleware";
|
|
17
18
|
import { patAllows } from "./pat-scope";
|
|
18
19
|
import { requestContext } from "./request-context";
|
|
20
|
+
import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
|
|
19
21
|
|
|
20
22
|
export function createApiRoutes(dispatcher: Dispatcher) {
|
|
21
23
|
const api = new Hono();
|
|
@@ -121,6 +123,61 @@ export function createApiRoutes(dispatcher: Dispatcher) {
|
|
|
121
123
|
}
|
|
122
124
|
});
|
|
123
125
|
|
|
126
|
+
// Dispatcher-driven SSE, full auth/CSRF/rate-limit chain (unlike the
|
|
127
|
+
// broker-based /sse route). Frame contract for clients: "chunk" (one per
|
|
128
|
+
// yielded value, JSON-encoded), "ping" (heartbeat, empty data), "done"
|
|
129
|
+
// (terminal, empty data), "error" (terminal, JSON error envelope — the
|
|
130
|
+
// response status stays 200 since SSE headers are already flushed before
|
|
131
|
+
// dispatch gates run on the generator's first pull).
|
|
132
|
+
api.post(Routes.stream, async (c) => {
|
|
133
|
+
const user = getUser(c);
|
|
134
|
+
const body = await c.req.json<{ type: string; payload: unknown }>();
|
|
135
|
+
const requestId = requestContext.get()?.requestId;
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
assertPatAllowed(user, body.type);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
return queryErrorResponse(c, toKumiko(e), body.type);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return streamSSE(c, async (stream) => {
|
|
144
|
+
const generator = dispatcher.stream(body.type, body.payload, user);
|
|
145
|
+
stream.onAbort(() => {
|
|
146
|
+
void generator.return(undefined);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
let pending = generator.next();
|
|
151
|
+
while (true) {
|
|
152
|
+
let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
+
const heartbeat = new Promise<"heartbeat">((resolve) => {
|
|
154
|
+
heartbeatTimer = setTimeout(() => resolve("heartbeat"), SSE_HEARTBEAT_INTERVAL_MS);
|
|
155
|
+
});
|
|
156
|
+
let outcome: Awaited<typeof pending> | "heartbeat";
|
|
157
|
+
try {
|
|
158
|
+
outcome = await Promise.race([pending, heartbeat]);
|
|
159
|
+
} finally {
|
|
160
|
+
clearTimeout(heartbeatTimer);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (outcome === "heartbeat") {
|
|
164
|
+
await stream.writeSSE({ event: "ping", data: "" });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (outcome.done) break;
|
|
168
|
+
await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
|
|
169
|
+
pending = generator.next();
|
|
170
|
+
}
|
|
171
|
+
await stream.writeSSE({ event: "done", data: "" });
|
|
172
|
+
} catch (e) {
|
|
173
|
+
const err = toKumiko(e);
|
|
174
|
+
logServerFault(err, requestId, body.type);
|
|
175
|
+
const { error } = serializeError(err, requestId);
|
|
176
|
+
await stream.writeSSE({ event: "error", data: stringifyJson(error) });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
124
181
|
return api;
|
|
125
182
|
}
|
|
126
183
|
|