@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.
Files changed (90) 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/auth-middleware.ts +17 -44
  11. package/src/api/auth-routes.ts +6 -2
  12. package/src/api/index.ts +1 -0
  13. package/src/api/routes.ts +57 -0
  14. package/src/api/server.ts +5 -4
  15. package/src/bun-db/query.ts +12 -25
  16. package/src/crypto/kms-adapter.ts +2 -118
  17. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  18. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
  19. package/src/db/cursor.ts +1 -18
  20. package/src/db/dialect.ts +8 -19
  21. package/src/db/entity-table-meta-types.ts +2 -92
  22. package/src/db/event-store-executor.ts +4 -96
  23. package/src/db/table-builder.ts +2 -19
  24. package/src/db/tenant-db.ts +6 -55
  25. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  26. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  27. package/src/engine/__tests__/engine.test.ts +28 -0
  28. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  29. package/src/engine/__tests__/registry.test.ts +40 -0
  30. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  31. package/src/engine/boot-validator/entity-handler.ts +10 -1
  32. package/src/engine/define-feature.ts +1 -0
  33. package/src/engine/define-handler.ts +1 -0
  34. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  35. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  36. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  37. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  38. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  39. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  40. package/src/engine/feature-ast/extractors/index.ts +1 -0
  41. package/src/engine/feature-ast/index.ts +2 -0
  42. package/src/engine/feature-ast/parse.ts +3 -0
  43. package/src/engine/feature-ast/patch.ts +2 -0
  44. package/src/engine/feature-ast/patcher.ts +21 -0
  45. package/src/engine/feature-ast/patterns.ts +16 -0
  46. package/src/engine/feature-ast/render.ts +15 -0
  47. package/src/engine/feature-builder-state.ts +3 -0
  48. package/src/engine/feature-entity-handlers.ts +35 -1
  49. package/src/engine/index.ts +3 -0
  50. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  51. package/src/engine/pattern-library/library.ts +2 -0
  52. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  53. package/src/engine/registry-facade.ts +9 -0
  54. package/src/engine/registry-ingest.ts +10 -0
  55. package/src/engine/registry-state.ts +3 -0
  56. package/src/engine/types/config.ts +2 -497
  57. package/src/engine/types/define-handler.ts +2 -94
  58. package/src/engine/types/entity-handlers.ts +2 -30
  59. package/src/engine/types/feature.ts +2 -1021
  60. package/src/engine/types/fields.ts +2 -685
  61. package/src/engine/types/handlers.ts +2 -820
  62. package/src/engine/types/hooks.ts +2 -170
  63. package/src/engine/types/index.ts +44 -36
  64. package/src/engine/types/nav.ts +2 -67
  65. package/src/engine/types/ownership.ts +2 -83
  66. package/src/engine/types/projection.ts +2 -165
  67. package/src/engine/types/screen.ts +2 -747
  68. package/src/engine/types/step.ts +2 -334
  69. package/src/engine/types/workspace.ts +2 -42
  70. package/src/errors/write-error-info.ts +6 -22
  71. package/src/event-store/errors.ts +2 -35
  72. package/src/event-store/event-store.ts +2 -21
  73. package/src/event-store/snapshot.ts +11 -35
  74. package/src/event-store/types.ts +2 -22
  75. package/src/files/provider-resolver.ts +3 -5
  76. package/src/files/types.ts +5 -54
  77. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  78. package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
  79. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  80. package/src/pipeline/dispatch-shared.ts +39 -1
  81. package/src/pipeline/dispatch-stream.ts +74 -0
  82. package/src/pipeline/dispatcher-utils.ts +1 -1
  83. package/src/pipeline/dispatcher.ts +7 -0
  84. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  85. package/src/rate-limit/resolver.ts +10 -30
  86. package/src/secrets/envelope-cipher.ts +4 -6
  87. package/src/secrets/types.ts +2 -177
  88. package/src/stack/request-helper.ts +19 -2
  89. package/src/stack/test-stack.ts +33 -14
  90. 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.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.159.1",
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.159.1",
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
+ });
@@ -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",
@@ -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?: AnonymousAccessConfig;
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
- // Single-tenant shortcut. When set, the server runs in **locked** mode:
100
- // - no client-supplied tenant: defaultTenantId is used.
101
- // - client supplies a matching tenant (header/cookie/resolver): allowed.
102
- // - client supplies a non-matching tenant: 400 tenant_mismatch (the
103
- // server is single-tenant; rejecting protects against confused clients
104
- // who think they're talking to a different deployment).
105
- // The framework does NOT verify defaultTenantId against the DB at boot;
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
- // Union, not one flat optional-everything type: a tenantResolver without a
116
- // declared resolverTrust is an ambiguous trust decision the compiler should
117
- // catch, not a silent runtime default. Set resolverTrust to:
118
- // - "authoritative": the resolver is trusted (e.g. it derives the tenant
119
- // from the subdomain, which the client cannot forge) and is consulted
120
- // FIRST. A client-supplied tenant that disagrees with the resolver's
121
- // answer is rejected with 400 tenant_mismatch — it is never used to
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: AnonymousAccessConfig,
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: AnonymousAccessConfig,
471
+ config: AnonymousAccessResolved,
499
472
  clientTenant: { id: TenantId; source: "header" | "cookie" } | null,
500
473
  ): Promise<ResolvedTenant | ResolveError> {
501
474
  if (config.defaultTenantId !== undefined) {