@cosmicdrift/kumiko-framework 0.165.0 → 2.0.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 +5 -3
- package/src/__tests__/consumer-cli.integration.test.ts +32 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +267 -19
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +135 -0
- package/src/api/__tests__/batch.integration.test.ts +21 -2
- package/src/api/__tests__/jwt.test.ts +52 -2
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/auth-routes.ts +165 -33
- package/src/api/index.ts +1 -0
- package/src/api/jwt.ts +22 -1
- package/src/api/routes.ts +103 -35
- package/src/api/server.ts +17 -1
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +12 -3
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-runner.test.ts +61 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- package/src/db/connection.ts +6 -3
- package/src/db/dialect.ts +1 -6
- package/src/db/entity-table-meta-types.ts +1 -1
- package/src/db/event-store-executor-context.ts +2 -3
- package/src/db/event-store-executor-read.ts +2 -3
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/index.ts +8 -1
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +107 -11
- package/src/db/pg-error.ts +8 -0
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-store.ts +97 -5
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +114 -12
- package/src/db/tenant-db.ts +2 -4
- package/src/engine/__tests__/engine.test.ts +30 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/store-table.test.ts +2 -2
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +32 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/extensions/user-data.ts +12 -4
- package/src/engine/feature-ui-extensions.ts +2 -2
- package/src/engine/hook-helpers.ts +3 -1
- package/src/engine/index.ts +1 -1
- package/src/engine/ownership.ts +4 -3
- package/src/engine/registry-ingest.ts +14 -14
- package/src/engine/registry-state.ts +4 -1
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/types/config.ts +1 -1
- package/src/engine/types/define-handler.ts +1 -1
- package/src/engine/types/entity-handlers.ts +1 -1
- package/src/engine/types/event-type-map.ts +1 -1
- package/src/engine/types/feature.ts +1 -1
- package/src/engine/types/fields.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +1 -1
- package/src/engine/types/http-route.ts +1 -1
- package/src/engine/types/nav.ts +1 -1
- package/src/engine/types/ownership.ts +1 -1
- package/src/engine/types/projection.ts +1 -1
- package/src/engine/types/relations.ts +1 -1
- package/src/engine/types/screen.ts +1 -1
- package/src/engine/types/step.ts +1 -1
- package/src/engine/types/target-ref.ts +1 -1
- package/src/engine/types/tree-node.ts +1 -1
- package/src/engine/types/workspace.ts +1 -1
- package/src/engine/validate-projection-allowlist.ts +5 -5
- package/src/errors/classes.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/write-error-info.ts +6 -2
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +19 -4
- package/src/event-store/types.ts +1 -1
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/write-stream.test.ts +3 -3
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +6 -1
- package/src/files/types.ts +8 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
- package/src/jobs/job-runner.ts +41 -11
- package/src/logging/types.ts +1 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +35 -2
- package/src/observability/types/index.ts +1 -1
- package/src/observability/types/metric.ts +1 -1
- package/src/observability/types/provider.ts +1 -1
- package/src/observability/types/span.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +151 -0
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +32 -11
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +20 -6
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +87 -0
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +6 -2
- package/src/schema-cli.ts +24 -12
- package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
- package/src/search/reindex-entity.ts +31 -2
- package/src/search/types.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +2 -1
- package/src/stack/request-helper.ts +20 -1
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +18 -15
- package/src/testing/__tests__/late-bound.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +6 -0
- package/src/testing/file-provider-contract.ts +26 -6
- package/src/testing/index.ts +1 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +37 -31
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "2.0.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,6 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.165.0",
|
|
186
185
|
"bullmq": "^5.76.7",
|
|
187
186
|
"bun-types": "^1.3.13",
|
|
188
187
|
"hono": "^4.12.18",
|
|
@@ -197,8 +196,11 @@
|
|
|
197
196
|
"uuid": "^14.0.0",
|
|
198
197
|
"zod": "^4.4.3"
|
|
199
198
|
},
|
|
199
|
+
"peerDependencies": {
|
|
200
|
+
"@cosmicdrift/kumiko-types": "^2.0.0"
|
|
201
|
+
},
|
|
200
202
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
203
|
+
"@cosmicdrift/kumiko-dispatcher-live": "2.0.0",
|
|
202
204
|
"bun-types": "^1.3.13",
|
|
203
205
|
"pino-pretty": "^13.1.3"
|
|
204
206
|
},
|
|
@@ -98,6 +98,38 @@ describe("runConsumerCli status", () => {
|
|
|
98
98
|
const joined = lines.join("\n");
|
|
99
99
|
expect(joined).toContain('instance_id="inst-1"');
|
|
100
100
|
});
|
|
101
|
+
|
|
102
|
+
test("--instance-id=<id> form is recognised (#1412)", async () => {
|
|
103
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
104
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
105
|
+
await insertConsumerIfAbsent(testDb.db, "test:consumer:eq", "inst-2");
|
|
106
|
+
const { out, lines } = captureOut();
|
|
107
|
+
const code = await runConsumerCli(["status", "test:consumer:eq", "--instance-id=inst-2"], out);
|
|
108
|
+
expect(code).toBe(0);
|
|
109
|
+
expect(lines.join("\n")).toContain('instance_id="inst-2"');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("--instance-id before the name still parses the name correctly (#1412)", async () => {
|
|
113
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
114
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
115
|
+
await insertConsumerIfAbsent(testDb.db, "test:consumer:order", "inst-3");
|
|
116
|
+
const { out, lines } = captureOut();
|
|
117
|
+
const code = await runConsumerCli(
|
|
118
|
+
["status", "--instance-id", "inst-3", "test:consumer:order"],
|
|
119
|
+
out,
|
|
120
|
+
);
|
|
121
|
+
expect(code).toBe(0);
|
|
122
|
+
expect(lines.join("\n")).toContain('instance_id="inst-3"');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("--instance-id with a missing value errors instead of silently falling back to shared (#1412)", async () => {
|
|
126
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
127
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
128
|
+
const { out, lines } = captureOut();
|
|
129
|
+
const code = await runConsumerCli(["status", "test:consumer:foo", "--instance-id"], out);
|
|
130
|
+
expect(code).toBe(1);
|
|
131
|
+
expect(lines.join("\n")).toContain("--instance-id braucht einen Wert");
|
|
132
|
+
});
|
|
101
133
|
});
|
|
102
134
|
|
|
103
135
|
describe("runConsumerCli — unknown/empty subcommand exit codes", () => {
|
|
@@ -177,7 +177,7 @@ describe("runSchemaCli — validate (static CI gate, no DB)", () => {
|
|
|
177
177
|
const code = await runSchemaCli(["validate"], appCwd, cap.out);
|
|
178
178
|
expect(code).toBe(0);
|
|
179
179
|
expect(cap.log.join("\n")).toContain("migrations match");
|
|
180
|
-
expect(cap.log.join("\n")).toContain("
|
|
180
|
+
expect(cap.log.join("\n")).toContain("table/column names match .snapshot.json");
|
|
181
181
|
});
|
|
182
182
|
|
|
183
183
|
// Reproduces the kumiko-studio 0016 incident: a migration file gets
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { parseSseFrames } from "@cosmicdrift/kumiko-dispatcher-live";
|
|
3
|
+
import { Hono } from "hono";
|
|
2
4
|
import { z } from "zod";
|
|
3
5
|
import {
|
|
4
6
|
createEntity,
|
|
@@ -7,8 +9,10 @@ import {
|
|
|
7
9
|
defineFeature,
|
|
8
10
|
type TenantId,
|
|
9
11
|
} from "../../engine";
|
|
12
|
+
import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
|
|
10
13
|
import { createTestUser, TestUsers } from "../../stack";
|
|
11
|
-
import {
|
|
14
|
+
import { waitFor } from "../../testing";
|
|
15
|
+
import { createApiRoutes, pumpStream, StreamFrame } from "../routes";
|
|
12
16
|
import { buildServer } from "../server";
|
|
13
17
|
|
|
14
18
|
const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
|
|
@@ -40,6 +44,27 @@ const testFeature = defineFeature("test", (r) => {
|
|
|
40
44
|
},
|
|
41
45
|
{ access: { roles: ["Admin"] } },
|
|
42
46
|
);
|
|
47
|
+
|
|
48
|
+
r.streamHandler(
|
|
49
|
+
"item:tail-fail-mid",
|
|
50
|
+
z.object({}),
|
|
51
|
+
async function* () {
|
|
52
|
+
yield { i: 0 };
|
|
53
|
+
yield { i: 1 };
|
|
54
|
+
throw new Error("boom");
|
|
55
|
+
},
|
|
56
|
+
{ access: { roles: ["Admin"] } },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
r.streamHandler(
|
|
60
|
+
"item:tail-fail-first",
|
|
61
|
+
z.object({}),
|
|
62
|
+
// biome-ignore lint/correctness/useYield: deliberately throws before any yield — tests the pre-pull failure path
|
|
63
|
+
async function* () {
|
|
64
|
+
throw new Error("boom");
|
|
65
|
+
},
|
|
66
|
+
{ access: { roles: ["Admin"] } },
|
|
67
|
+
);
|
|
43
68
|
});
|
|
44
69
|
|
|
45
70
|
const registry = createRegistry([testFeature]);
|
|
@@ -268,6 +293,79 @@ describe("pumpStream", () => {
|
|
|
268
293
|
// The chunk before the throw still made it out.
|
|
269
294
|
expect(writer.frames.map((f) => f.event)).toEqual(["chunk"]);
|
|
270
295
|
});
|
|
296
|
+
|
|
297
|
+
test("closes the generator when writeSSE throws mid-loop (e.g. client disconnected on a ping)", async () => {
|
|
298
|
+
let cleanedUp = false;
|
|
299
|
+
async function* gen() {
|
|
300
|
+
try {
|
|
301
|
+
await Bun.sleep(50);
|
|
302
|
+
yield { i: 0 };
|
|
303
|
+
} finally {
|
|
304
|
+
cleanedUp = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
const frames: Array<{ event: string; data: string }> = [];
|
|
308
|
+
const writer = {
|
|
309
|
+
frames,
|
|
310
|
+
async writeSSE(message: { event: string; data: string }) {
|
|
311
|
+
if (message.event === StreamFrame.ping) throw new Error("client disconnected");
|
|
312
|
+
frames.push(message);
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
await expect(pumpStream(writer, gen(), 5)).rejects.toThrow("client disconnected");
|
|
317
|
+
// Fire-and-forget cleanup (kumiko-framework#1547): pumpStream's finally
|
|
318
|
+
// no longer awaits generator.return() — it can't, since a still-pending
|
|
319
|
+
// .next() would make that await hang — so cleanedUp flips asynchronously
|
|
320
|
+
// after pumpStream's own rejection, not synchronously before it.
|
|
321
|
+
await waitFor(() => {
|
|
322
|
+
expect(cleanedUp).toBe(true);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("does not hang when writeSSE throws while a .next() pull is still in flight", async () => {
|
|
327
|
+
// kumiko-framework#1547: generator.return() queues behind an in-flight
|
|
328
|
+
// .next() (V8 semantics) — an `await generator.return(undefined)` in the
|
|
329
|
+
// finally block would hang for as long as the handler's .next() never
|
|
330
|
+
// settles, which a dead Redis/DB subscription can do indefinitely.
|
|
331
|
+
// `never` intentionally never resolves — the fire-and-forget fix must
|
|
332
|
+
// not need it to.
|
|
333
|
+
const never = new Promise<never>(() => {});
|
|
334
|
+
async function* gen() {
|
|
335
|
+
await never;
|
|
336
|
+
yield { i: 0 };
|
|
337
|
+
}
|
|
338
|
+
const writer = {
|
|
339
|
+
frames: [] as Array<{ event: string; data: string }>,
|
|
340
|
+
async writeSSE(message: { event: string; data: string }) {
|
|
341
|
+
if (message.event === StreamFrame.ping) throw new Error("client disconnected");
|
|
342
|
+
this.frames.push(message);
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
const TIMEOUT = Symbol("timeout");
|
|
347
|
+
const outcome = await Promise.race([
|
|
348
|
+
pumpStream(writer, gen(), 5).then(
|
|
349
|
+
() => "resolved",
|
|
350
|
+
(e) => e,
|
|
351
|
+
),
|
|
352
|
+
Bun.sleep(500).then(() => TIMEOUT),
|
|
353
|
+
]);
|
|
354
|
+
expect(outcome).not.toBe(TIMEOUT);
|
|
355
|
+
expect(outcome).toBeInstanceOf(Error);
|
|
356
|
+
expect((outcome as Error).message).toBe("client disconnected");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("serializes an undefined yielded value as an explicit null chunk instead of an invalid frame", async () => {
|
|
360
|
+
const writer = fakeSseWriter();
|
|
361
|
+
async function* gen() {
|
|
362
|
+
yield undefined;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
await pumpStream(writer, gen(), 1000);
|
|
366
|
+
|
|
367
|
+
expect(writer.frames[0]).toEqual({ event: StreamFrame.chunk, data: "null" });
|
|
368
|
+
});
|
|
271
369
|
});
|
|
272
370
|
|
|
273
371
|
// --- SSE ---
|
|
@@ -288,17 +386,6 @@ describe("GET /api/sse", () => {
|
|
|
288
386
|
|
|
289
387
|
// --- Stream (dispatcher-driven SSE) ---
|
|
290
388
|
|
|
291
|
-
function parseSseFrames(text: string): Array<{ event: string; data: string }> {
|
|
292
|
-
return text
|
|
293
|
-
.split("\n\n")
|
|
294
|
-
.filter((frame) => frame.trim().length > 0)
|
|
295
|
-
.map((frame) => {
|
|
296
|
-
const event = /^event: (.*)$/m.exec(frame)?.[1] ?? "";
|
|
297
|
-
const data = /^data: (.*)$/m.exec(frame)?.[1] ?? "";
|
|
298
|
-
return { event, data };
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
|
|
302
389
|
describe("POST /api/stream", () => {
|
|
303
390
|
test("dispatches stream handler and yields chunk frames then done", async () => {
|
|
304
391
|
const headers = await authHeader(adminUser);
|
|
@@ -320,10 +407,11 @@ describe("POST /api/stream", () => {
|
|
|
320
407
|
]);
|
|
321
408
|
});
|
|
322
409
|
|
|
323
|
-
test("access-denied gate surfaces as
|
|
324
|
-
// Dispatch gates (feature/rate-limit/access/validation)
|
|
325
|
-
// generator's first pull, which
|
|
326
|
-
//
|
|
410
|
+
test("access-denied gate surfaces as a real 403, not an HTTP 200 error frame", async () => {
|
|
411
|
+
// Dispatch gates (feature/rate-limit/access/validation) run on the
|
|
412
|
+
// generator's first pull, which the route now performs BEFORE opening
|
|
413
|
+
// the SSE response — so a gate failure maps to its real HTTP status
|
|
414
|
+
// instead of a flushed-200 error frame (framework#1517).
|
|
327
415
|
const headers = await authHeader(guestUser);
|
|
328
416
|
const res = await req(
|
|
329
417
|
"POST",
|
|
@@ -332,11 +420,171 @@ describe("POST /api/stream", () => {
|
|
|
332
420
|
headers,
|
|
333
421
|
);
|
|
334
422
|
|
|
423
|
+
expect(res.status).toBe(403);
|
|
424
|
+
expect(res.headers.get("content-type")).not.toContain("text/event-stream");
|
|
425
|
+
const body = await res.json();
|
|
426
|
+
expect(body.error).toMatchObject({ code: "access_denied" });
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
test("returns 404 for unknown stream handler", async () => {
|
|
430
|
+
const headers = await authHeader(adminUser);
|
|
431
|
+
const res = await req("POST", "/api/stream", { type: "nope", payload: {} }, headers);
|
|
432
|
+
|
|
433
|
+
expect(res.status).toBe(404);
|
|
434
|
+
expect(res.headers.get("content-type")).not.toContain("text/event-stream");
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("returns 400 for schema validation failure", async () => {
|
|
438
|
+
const headers = await authHeader(adminUser);
|
|
439
|
+
const res = await req(
|
|
440
|
+
"POST",
|
|
441
|
+
"/api/stream",
|
|
442
|
+
{ type: "test:stream:item:tail", payload: { count: -1 } },
|
|
443
|
+
headers,
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
expect(res.status).toBe(400);
|
|
447
|
+
expect(res.headers.get("content-type")).not.toContain("text/event-stream");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("handler failure after chunks already sent stays HTTP 200 with an error frame", async () => {
|
|
451
|
+
// Boundary between the pre-pull fix and the still-open SSE stream: a
|
|
452
|
+
// failure that happens AFTER the first chunk (headers already flushed)
|
|
453
|
+
// must keep surfacing as a 200 + "error" frame, not a real HTTP status.
|
|
454
|
+
const headers = await authHeader(adminUser);
|
|
455
|
+
const res = await req(
|
|
456
|
+
"POST",
|
|
457
|
+
"/api/stream",
|
|
458
|
+
{ type: "test:stream:item:tail-fail-mid", payload: {} },
|
|
459
|
+
headers,
|
|
460
|
+
);
|
|
461
|
+
|
|
335
462
|
expect(res.status).toBe(200);
|
|
463
|
+
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
|
336
464
|
const frames = parseSseFrames(await res.text());
|
|
337
|
-
expect(frames).toHaveLength(
|
|
338
|
-
expect(frames
|
|
339
|
-
|
|
465
|
+
expect(frames).toHaveLength(3);
|
|
466
|
+
expect(frames.slice(0, 2)).toEqual([
|
|
467
|
+
{ event: "chunk", data: JSON.stringify({ i: 0 }) },
|
|
468
|
+
{ event: "chunk", data: JSON.stringify({ i: 1 }) },
|
|
469
|
+
]);
|
|
470
|
+
expect(frames[2]?.event).toBe("error");
|
|
471
|
+
expect(JSON.parse(frames[2]?.data ?? "{}")).toMatchObject({ code: "internal_error" });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("handler failure before the first yield surfaces as a real HTTP 500, not an SSE error frame", async () => {
|
|
475
|
+
// Contract-pin for routes.ts's "error frames only reachable once the
|
|
476
|
+
// stream is already open" comment: a generator that throws before its
|
|
477
|
+
// first yield is caught by the same pre-pull gate as feature/access/
|
|
478
|
+
// rate-limit/validation failures — the client sees 500 + JSON, not a
|
|
479
|
+
// 200 with an SSE error frame.
|
|
480
|
+
const headers = await authHeader(adminUser);
|
|
481
|
+
const res = await req(
|
|
482
|
+
"POST",
|
|
483
|
+
"/api/stream",
|
|
484
|
+
{ type: "test:stream:item:tail-fail-first", payload: {} },
|
|
485
|
+
headers,
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
expect(res.status).toBe(500);
|
|
489
|
+
expect(res.headers.get("content-type")).not.toContain("text/event-stream");
|
|
490
|
+
const body = await res.json();
|
|
491
|
+
expect(body.error).toMatchObject({ code: "internal_error" });
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
// --- POST /api/stream pre-pull race (framework#1547) ---
|
|
496
|
+
// Mount createApiRoutes with a short sseHeartbeatMs so the pre-pull heartbeat
|
|
497
|
+
// race is testable without waiting for the production 15s interval.
|
|
498
|
+
|
|
499
|
+
describe("POST /api/stream pre-pull race", () => {
|
|
500
|
+
const user = createTestUser({ roles: ["Admin"] });
|
|
501
|
+
|
|
502
|
+
function stubDispatcher(streamImpl: Dispatcher["stream"]): Dispatcher {
|
|
503
|
+
return {
|
|
504
|
+
async write(): Promise<WriteResult> {
|
|
505
|
+
return { isSuccess: true, data: {} };
|
|
506
|
+
},
|
|
507
|
+
async query(): Promise<unknown> {
|
|
508
|
+
return [];
|
|
509
|
+
},
|
|
510
|
+
stream: streamImpl,
|
|
511
|
+
async command(): Promise<void> {},
|
|
512
|
+
async batch(): Promise<BatchResult> {
|
|
513
|
+
return { isSuccess: true, results: [] };
|
|
514
|
+
},
|
|
515
|
+
async resolveAuthClaims(): Promise<Record<string, unknown>> {
|
|
516
|
+
return {};
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function mountStreamApp(dispatcher: Dispatcher, sseHeartbeatMs: number) {
|
|
522
|
+
const app = new Hono<{ Variables: { pipelineUser: typeof user } }>();
|
|
523
|
+
app.use("/api/*", async (c, next) => {
|
|
524
|
+
c.set("pipelineUser", user);
|
|
525
|
+
await next();
|
|
526
|
+
});
|
|
527
|
+
app.route("/api", createApiRoutes(dispatcher, { sseHeartbeatMs }));
|
|
528
|
+
return app;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
test("slow first next() opens 200 SSE with ping frames instead of hanging as HTTP error", async () => {
|
|
532
|
+
// First .next() takes longer than heartbeatMs → settledInTime=false →
|
|
533
|
+
// streamSSE opens immediately and pumpStream emits ping until the chunk
|
|
534
|
+
// arrives (framework#1547 route-level contract).
|
|
535
|
+
const dispatcher = stubDispatcher(async function* () {
|
|
536
|
+
await Bun.sleep(60);
|
|
537
|
+
yield { i: 0 };
|
|
538
|
+
});
|
|
539
|
+
const app = mountStreamApp(dispatcher, 20);
|
|
540
|
+
const res = await app.request(
|
|
541
|
+
new Request("http://localhost/api/stream", {
|
|
542
|
+
method: "POST",
|
|
543
|
+
headers: { "Content-Type": "application/json" },
|
|
544
|
+
body: JSON.stringify({ type: "any:stream:tail", payload: {} }),
|
|
545
|
+
}),
|
|
546
|
+
);
|
|
547
|
+
expect(res.status).toBe(200);
|
|
548
|
+
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
|
549
|
+
const frames = parseSseFrames(await res.text());
|
|
550
|
+
const events = frames.map((f) => f.event);
|
|
551
|
+
expect(events).toContain("ping");
|
|
552
|
+
expect(events).toContain("chunk");
|
|
553
|
+
expect(events.at(-1)).toBe("done");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("client abort during pre-pull returns 499 and runs generator cleanup", async () => {
|
|
557
|
+
// Finite sleep (not a never-resolving await): V8 queues .return() behind an
|
|
558
|
+
// in-flight .next(), so cleanup only runs once the pending pull settles.
|
|
559
|
+
// Abort before heartbeatMs so the route hits the 499 branch; sleep then
|
|
560
|
+
// completes and the queued return drains the generator's finally.
|
|
561
|
+
let cleanedUp = false;
|
|
562
|
+
const dispatcher = stubDispatcher(async function* () {
|
|
563
|
+
try {
|
|
564
|
+
await Bun.sleep(80);
|
|
565
|
+
yield { i: 0 };
|
|
566
|
+
} finally {
|
|
567
|
+
cleanedUp = true;
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
const app = mountStreamApp(dispatcher, 40);
|
|
571
|
+
const ac = new AbortController();
|
|
572
|
+
const pending = app.request(
|
|
573
|
+
new Request("http://localhost/api/stream", {
|
|
574
|
+
method: "POST",
|
|
575
|
+
headers: { "Content-Type": "application/json" },
|
|
576
|
+
body: JSON.stringify({ type: "any:stream:tail", payload: {} }),
|
|
577
|
+
signal: ac.signal,
|
|
578
|
+
}),
|
|
579
|
+
);
|
|
580
|
+
// Abort while firstPull is still racing the heartbeat timer.
|
|
581
|
+
await Bun.sleep(5);
|
|
582
|
+
ac.abort();
|
|
583
|
+
const res = await pending;
|
|
584
|
+
expect(res.status).toBe(499);
|
|
585
|
+
await waitFor(() => {
|
|
586
|
+
expect(cleanedUp).toBe(true);
|
|
587
|
+
});
|
|
340
588
|
});
|
|
341
589
|
});
|
|
342
590
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// authMiddleware boots eagerly (not per-request) so a tenantResolver
|
|
2
|
+
// declared without resolverTrust is an ambiguous trust decision that fails
|
|
3
|
+
// loud at startup, instead of silently letting a client-set tenant header
|
|
4
|
+
// override a host-derived resolver at request time (#1452).
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { authMiddleware } from "../auth-middleware";
|
|
8
|
+
import { createJwtHelper } from "../jwt";
|
|
9
|
+
|
|
10
|
+
const JWT_SECRET = "auth-middleware-anon-access-boot-test-secret-32ch";
|
|
11
|
+
|
|
12
|
+
describe("authMiddleware anonymousAccess boot guard", () => {
|
|
13
|
+
test("throws at construction when tenantResolver is set without resolverTrust", () => {
|
|
14
|
+
const jwt = createJwtHelper(JWT_SECRET);
|
|
15
|
+
expect(() =>
|
|
16
|
+
authMiddleware(jwt, {
|
|
17
|
+
anonymousAccess: {
|
|
18
|
+
tenantResolver: () => "t1" as never,
|
|
19
|
+
},
|
|
20
|
+
}),
|
|
21
|
+
).toThrow(/resolverTrust/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("does not throw when resolverTrust is declared", () => {
|
|
25
|
+
const jwt = createJwtHelper(JWT_SECRET);
|
|
26
|
+
expect(() =>
|
|
27
|
+
authMiddleware(jwt, {
|
|
28
|
+
anonymousAccess: {
|
|
29
|
+
tenantResolver: () => "t1" as never,
|
|
30
|
+
resolverTrust: "authoritative",
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
).not.toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("does not throw when no tenantResolver is configured", () => {
|
|
37
|
+
const jwt = createJwtHelper(JWT_SECRET);
|
|
38
|
+
expect(() => authMiddleware(jwt, {})).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -92,6 +92,22 @@ describe("POST /auth/login — invalid_body", () => {
|
|
|
92
92
|
describe("POST /auth/invite-accept", () => {
|
|
93
93
|
test("requires JWT — not a public route", async () => {
|
|
94
94
|
expect(PUBLIC_API_PATHS.has("/api/auth/invite-accept")).toBe(false);
|
|
95
|
+
|
|
96
|
+
let dispatched = false;
|
|
97
|
+
const dispatcher = createStubDispatcher({
|
|
98
|
+
async write(): Promise<WriteResult> {
|
|
99
|
+
dispatched = true;
|
|
100
|
+
return { isSuccess: true, data: { kind: "noop" } };
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
const { app } = await buildApp({ invite: inviteConfig }, dispatcher);
|
|
104
|
+
const res = await app.request("/api/auth/invite-accept", {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json" },
|
|
107
|
+
body: JSON.stringify({ inviteToken: "t" }),
|
|
108
|
+
});
|
|
109
|
+
expect(res.status).toBe(401);
|
|
110
|
+
expect(dispatched).toBe(false);
|
|
95
111
|
});
|
|
96
112
|
|
|
97
113
|
test("400 invalid_body before dispatch", async () => {
|
|
@@ -80,11 +80,12 @@ function preauthConfirmRequest(body: unknown): Request {
|
|
|
80
80
|
|
|
81
81
|
describe("POST /auth/mfa/preauth-confirm", () => {
|
|
82
82
|
test("is public — reachable without a JWT", async () => {
|
|
83
|
+
expect(PUBLIC_API_PATHS.has("/api/auth/mfa/preauth-confirm")).toBe(true);
|
|
83
84
|
const { app } = await buildApp();
|
|
84
85
|
const res = await app.request(preauthConfirmRequest({ setupToken: "t", code: "123456" }));
|
|
85
86
|
// No Authorization header sent at all — if the route required a JWT,
|
|
86
87
|
// authMiddleware would reject with 401 before the handler ever runs.
|
|
87
|
-
expect(res.status).
|
|
88
|
+
expect(res.status).toBe(200);
|
|
88
89
|
});
|
|
89
90
|
|
|
90
91
|
test("not mounted when mfaPreauthConfirmHandler is unset", async () => {
|
|
@@ -11,7 +11,11 @@ import { InternalError, UnprocessableError } from "../../errors";
|
|
|
11
11
|
import type { BatchResult, Dispatcher, WriteResult } from "../../pipeline/dispatcher";
|
|
12
12
|
import { PUBLIC_API_PATHS } from "../api-constants";
|
|
13
13
|
import { authMiddleware } from "../auth-middleware";
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
type AuthRoutesConfig,
|
|
16
|
+
createAuthRoutes,
|
|
17
|
+
createInMemoryLoginRateLimiter,
|
|
18
|
+
} from "../auth-routes";
|
|
15
19
|
import { createJwtHelper } from "../jwt";
|
|
16
20
|
|
|
17
21
|
const JWT_SECRET = "test-jwt-secret-at-least-32-bytes-long!!";
|
|
@@ -183,4 +187,63 @@ describe("POST /auth/mfa/preauth-enable-start", () => {
|
|
|
183
187
|
);
|
|
184
188
|
expect(res.status).toBe(500);
|
|
185
189
|
});
|
|
190
|
+
|
|
191
|
+
// #1466: this route inherited zero rate-limiting — a preauthSetupToken is
|
|
192
|
+
// valid (and not single-use) for its whole TTL, so an unrated-limited
|
|
193
|
+
// replay is a memory-hard CPU amplifier (8 argon2id recovery-code hashes
|
|
194
|
+
// per hit). Mirrors mfaPreauthConfirmRateLimit's own regression test.
|
|
195
|
+
test("mfaPreauthEnableStartRateLimit caps replays — 429 after its own limit", async () => {
|
|
196
|
+
const { app } = await buildApp({
|
|
197
|
+
mfaPreauthEnableStartRateLimit: createInMemoryLoginRateLimiter(2, 60_000),
|
|
198
|
+
});
|
|
199
|
+
const attempt = () =>
|
|
200
|
+
app.request(preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }));
|
|
201
|
+
expect((await attempt()).status).toBe(200);
|
|
202
|
+
expect((await attempt()).status).toBe(200);
|
|
203
|
+
const third = await attempt();
|
|
204
|
+
expect(third.status).toBe(429);
|
|
205
|
+
const body = (await third.json()) as { isSuccess: boolean; error: string };
|
|
206
|
+
expect(body.error).toBe("rate_limited");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// #1522 / #1545: the IP axis alone is bypassable by rotating
|
|
210
|
+
// x-forwarded-for. The second check keys on a sha256 of the
|
|
211
|
+
// preauthSetupToken — prove rotating IPs still hit 429 on the same
|
|
212
|
+
// token, and that a different token does not share that bucket.
|
|
213
|
+
test("preauthSetupToken rate-limit axis survives x-forwarded-for rotation", async () => {
|
|
214
|
+
const { app } = await buildApp({
|
|
215
|
+
mfaPreauthEnableStartRateLimit: createInMemoryLoginRateLimiter(2, 60_000),
|
|
216
|
+
});
|
|
217
|
+
const attempt = (token: string, forwardedFor: string) =>
|
|
218
|
+
app.request(
|
|
219
|
+
new Request("http://localhost/api/auth/mfa/preauth-enable-start", {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: {
|
|
222
|
+
"Content-Type": "application/json",
|
|
223
|
+
"x-forwarded-for": forwardedFor,
|
|
224
|
+
},
|
|
225
|
+
body: JSON.stringify({ preauthSetupToken: token, accountLabel: "a@b.c" }),
|
|
226
|
+
}),
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
expect((await attempt("shared-token", "10.0.0.1")).status).toBe(200);
|
|
230
|
+
expect((await attempt("shared-token", "10.0.0.2")).status).toBe(200);
|
|
231
|
+
const blocked = await attempt("shared-token", "10.0.0.3");
|
|
232
|
+
expect(blocked.status).toBe(429);
|
|
233
|
+
const body = (await blocked.json()) as { isSuccess: boolean; error: string };
|
|
234
|
+
expect(body.error).toBe("rate_limited");
|
|
235
|
+
|
|
236
|
+
// Distinct token → independent bucket; rotating IP must not inherit the
|
|
237
|
+
// prior token's exhaustion.
|
|
238
|
+
expect((await attempt("other-token", "10.0.0.4")).status).toBe(200);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("mfaPreauthEnableStartRateLimit: null disables rate-limiting", async () => {
|
|
242
|
+
const { app } = await buildApp({ mfaPreauthEnableStartRateLimit: null });
|
|
243
|
+
const attempt = () =>
|
|
244
|
+
app.request(preauthStartRequest({ preauthSetupToken: "t", accountLabel: "a@b.c" }));
|
|
245
|
+
for (let i = 0; i < 15; i++) {
|
|
246
|
+
expect((await attempt()).status).toBe(200);
|
|
247
|
+
}
|
|
248
|
+
});
|
|
186
249
|
});
|