@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.
Files changed (84) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +65 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  7. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  8. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  9. package/src/api/api-constants.ts +1 -0
  10. package/src/api/routes.ts +57 -0
  11. package/src/bun-db/query.ts +12 -25
  12. package/src/crypto/kms-adapter.ts +2 -118
  13. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  14. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
  15. package/src/db/cursor.ts +1 -18
  16. package/src/db/dialect.ts +8 -19
  17. package/src/db/entity-table-meta-types.ts +2 -92
  18. package/src/db/event-store-executor.ts +4 -96
  19. package/src/db/table-builder.ts +2 -19
  20. package/src/db/tenant-db.ts +6 -55
  21. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  22. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  23. package/src/engine/__tests__/engine.test.ts +28 -0
  24. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  25. package/src/engine/__tests__/registry.test.ts +40 -0
  26. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  27. package/src/engine/boot-validator/entity-handler.ts +10 -1
  28. package/src/engine/define-feature.ts +1 -0
  29. package/src/engine/define-handler.ts +1 -0
  30. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  31. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  32. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  33. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  34. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  35. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  36. package/src/engine/feature-ast/extractors/index.ts +1 -0
  37. package/src/engine/feature-ast/index.ts +2 -0
  38. package/src/engine/feature-ast/parse.ts +3 -0
  39. package/src/engine/feature-ast/patch.ts +2 -0
  40. package/src/engine/feature-ast/patcher.ts +21 -0
  41. package/src/engine/feature-ast/patterns.ts +16 -0
  42. package/src/engine/feature-ast/render.ts +15 -0
  43. package/src/engine/feature-builder-state.ts +3 -0
  44. package/src/engine/feature-entity-handlers.ts +35 -1
  45. package/src/engine/index.ts +3 -0
  46. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  47. package/src/engine/pattern-library/library.ts +2 -0
  48. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  49. package/src/engine/registry-facade.ts +9 -0
  50. package/src/engine/registry-ingest.ts +10 -0
  51. package/src/engine/registry-state.ts +3 -0
  52. package/src/engine/types/config.ts +2 -497
  53. package/src/engine/types/define-handler.ts +2 -94
  54. package/src/engine/types/entity-handlers.ts +2 -30
  55. package/src/engine/types/feature.ts +2 -1021
  56. package/src/engine/types/fields.ts +2 -685
  57. package/src/engine/types/handlers.ts +2 -820
  58. package/src/engine/types/hooks.ts +2 -170
  59. package/src/engine/types/index.ts +44 -36
  60. package/src/engine/types/nav.ts +2 -67
  61. package/src/engine/types/ownership.ts +2 -83
  62. package/src/engine/types/projection.ts +2 -165
  63. package/src/engine/types/screen.ts +2 -747
  64. package/src/engine/types/step.ts +2 -334
  65. package/src/engine/types/workspace.ts +2 -42
  66. package/src/errors/write-error-info.ts +6 -22
  67. package/src/event-store/errors.ts +2 -35
  68. package/src/event-store/event-store.ts +2 -21
  69. package/src/event-store/snapshot.ts +11 -35
  70. package/src/event-store/types.ts +2 -22
  71. package/src/files/provider-resolver.ts +3 -5
  72. package/src/files/types.ts +5 -54
  73. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  74. package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
  75. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  76. package/src/pipeline/dispatch-shared.ts +39 -1
  77. package/src/pipeline/dispatch-stream.ts +74 -0
  78. package/src/pipeline/dispatcher-utils.ts +1 -1
  79. package/src/pipeline/dispatcher.ts +7 -0
  80. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  81. package/src/rate-limit/resolver.ts +10 -30
  82. package/src/secrets/envelope-cipher.ts +4 -6
  83. package/src/secrets/types.ts +2 -177
  84. 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.159.1",
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.159.1",
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.159.1",
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
+ });
@@ -10,6 +10,7 @@ export const Routes = {
10
10
  query: "/query",
11
11
  command: "/command",
12
12
  sse: "/sse",
13
+ stream: "/stream",
13
14
  auth: "/auth",
14
15
  authLogin: "/auth/login",
15
16
  authMfaVerify: "/auth/mfa/verify",
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