@cosmicdrift/kumiko-framework 0.158.2 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +46 -27
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +5 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +17 -45
- package/src/secrets/types.ts +2 -177
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.160.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -147,6 +147,10 @@
|
|
|
147
147
|
"types": "./src/schema-cli.ts",
|
|
148
148
|
"default": "./src/schema-cli.ts"
|
|
149
149
|
},
|
|
150
|
+
"./consumer-cli": {
|
|
151
|
+
"types": "./src/consumer-cli.ts",
|
|
152
|
+
"default": "./src/consumer-cli.ts"
|
|
153
|
+
},
|
|
150
154
|
"./stack": {
|
|
151
155
|
"types": "./src/stack/index.ts",
|
|
152
156
|
"default": "./src/stack/index.ts"
|
|
@@ -178,6 +182,7 @@
|
|
|
178
182
|
"./package.json": "./package.json"
|
|
179
183
|
},
|
|
180
184
|
"dependencies": {
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.160.0",
|
|
181
186
|
"bullmq": "^5.76.7",
|
|
182
187
|
"bun-types": "^1.3.13",
|
|
183
188
|
"hono": "^4.12.18",
|
|
@@ -193,7 +198,7 @@
|
|
|
193
198
|
"zod": "^4.4.3"
|
|
194
199
|
},
|
|
195
200
|
"devDependencies": {
|
|
196
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.160.0",
|
|
197
202
|
"bun-types": "^1.3.13",
|
|
198
203
|
"pino-pretty": "^13.1.3"
|
|
199
204
|
},
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Integration test for `runConsumerCli` (kumiko-framework#1351).
|
|
2
|
+
//
|
|
3
|
+
// Recovery semantics themselves are already covered by
|
|
4
|
+
// event-dispatcher-recovery — this only exercises the CLI layer: argv
|
|
5
|
+
// parsing, DB-connection handling, exit codes, output formatting.
|
|
6
|
+
|
|
7
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import { type BunTestDb, createTestDb } from "../bun-db/__tests__/bun-test-db";
|
|
9
|
+
import { type ConsumerCliOut, runConsumerCli } from "../consumer-cli";
|
|
10
|
+
import { insertConsumerIfAbsent, markConsumerRebuildFailed } from "../db/queries/event-consumer";
|
|
11
|
+
import { asRawClient } from "../db/query";
|
|
12
|
+
import { createEventConsumerStateTable } from "../pipeline";
|
|
13
|
+
import { ensureTemporalPolyfill } from "../time/polyfill";
|
|
14
|
+
|
|
15
|
+
const SHARED = "__shared__";
|
|
16
|
+
|
|
17
|
+
let testDb: BunTestDb;
|
|
18
|
+
let testUrl: string;
|
|
19
|
+
let prevDbUrl: string | undefined;
|
|
20
|
+
|
|
21
|
+
beforeAll(async () => {
|
|
22
|
+
await ensureTemporalPolyfill();
|
|
23
|
+
testDb = await createTestDb();
|
|
24
|
+
const baseUrl = process.env["TEST_DATABASE_URL"];
|
|
25
|
+
if (!baseUrl) throw new Error("TEST_DATABASE_URL not set — required for this test file");
|
|
26
|
+
testUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
|
|
27
|
+
await createEventConsumerStateTable(testDb.db);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterAll(async () => {
|
|
31
|
+
await testDb.cleanup();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(async () => {
|
|
35
|
+
await asRawClient(testDb.db).unsafe(`TRUNCATE TABLE "kumiko_event_consumers"`);
|
|
36
|
+
if (prevDbUrl === undefined) delete process.env["DATABASE_URL"];
|
|
37
|
+
else process.env["DATABASE_URL"] = prevDbUrl;
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function captureOut(): { out: ConsumerCliOut; lines: string[] } {
|
|
41
|
+
const lines: string[] = [];
|
|
42
|
+
return {
|
|
43
|
+
out: { log: (l: string) => lines.push(l), err: (l: string) => lines.push(`ERR ${l}`) },
|
|
44
|
+
lines,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("runConsumerCli status", () => {
|
|
49
|
+
test("unknown consumer → exit 1, not-found message", async () => {
|
|
50
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
51
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
52
|
+
const { out, lines } = captureOut();
|
|
53
|
+
const code = await runConsumerCli(["status", "no-such-consumer"], out);
|
|
54
|
+
expect(code).toBe(1);
|
|
55
|
+
expect(lines.join("\n")).toContain("not found");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("known consumer → exit 0, reports status/cursor/attempts", async () => {
|
|
59
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
60
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
61
|
+
await insertConsumerIfAbsent(testDb.db, "test:consumer:foo", SHARED);
|
|
62
|
+
const { out, lines } = captureOut();
|
|
63
|
+
const code = await runConsumerCli(["status", "test:consumer:foo"], out);
|
|
64
|
+
expect(code).toBe(0);
|
|
65
|
+
const joined = lines.join("\n");
|
|
66
|
+
expect(joined).toContain("test:consumer:foo");
|
|
67
|
+
expect(joined).toContain("status: idle");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("missing <name> → exit 1, usage message", async () => {
|
|
71
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
72
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
73
|
+
const { out, lines } = captureOut();
|
|
74
|
+
const code = await runConsumerCli(["status"], out);
|
|
75
|
+
expect(code).toBe(1);
|
|
76
|
+
expect(lines.join("\n")).toContain("Usage:");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("missing DATABASE_URL → exit 1", async () => {
|
|
80
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
81
|
+
delete process.env["DATABASE_URL"];
|
|
82
|
+
const { out, lines } = captureOut();
|
|
83
|
+
const code = await runConsumerCli(["status", "whatever"], out);
|
|
84
|
+
expect(code).toBe(1);
|
|
85
|
+
expect(lines.join("\n")).toContain("DATABASE_URL not set");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("runConsumerCli restart", () => {
|
|
90
|
+
test("dead consumer → idle, exit 0", async () => {
|
|
91
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
92
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
93
|
+
await insertConsumerIfAbsent(testDb.db, "test:consumer:bar", SHARED);
|
|
94
|
+
await markConsumerRebuildFailed(testDb.db, "test:consumer:bar", SHARED, "boom");
|
|
95
|
+
const { out, lines } = captureOut();
|
|
96
|
+
const code = await runConsumerCli(["restart", "test:consumer:bar"], out);
|
|
97
|
+
expect(code).toBe(0);
|
|
98
|
+
expect(lines.join("\n")).toContain("→ idle");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("non-dead consumer → propagated error, exit 1", async () => {
|
|
102
|
+
prevDbUrl = process.env["DATABASE_URL"];
|
|
103
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
104
|
+
await insertConsumerIfAbsent(testDb.db, "test:consumer:baz", SHARED);
|
|
105
|
+
const { out, lines } = captureOut();
|
|
106
|
+
const code = await runConsumerCli(["restart", "test:consumer:baz"], out);
|
|
107
|
+
expect(code).toBe(1);
|
|
108
|
+
expect(lines.join("\n")).toContain("not dead");
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -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: [] };
|
|
@@ -52,8 +53,11 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
52
53
|
async function buildApp(
|
|
53
54
|
overrides: Partial<AuthRoutesConfig> = {},
|
|
54
55
|
dispatcher: Dispatcher = createStubDispatcher(),
|
|
56
|
+
ttlSeconds?: number,
|
|
55
57
|
): Promise<{ app: Hono; validToken: string }> {
|
|
56
|
-
const jwt =
|
|
58
|
+
const jwt = ttlSeconds
|
|
59
|
+
? createJwtHelper(JWT_SECRET, undefined, ttlSeconds)
|
|
60
|
+
: createJwtHelper(JWT_SECRET);
|
|
57
61
|
const validToken = await jwt.sign(TestUsers.user);
|
|
58
62
|
const config: AuthRoutesConfig = {
|
|
59
63
|
membershipQuery: "tenant:query:memberships",
|
|
@@ -128,6 +132,18 @@ describe("auth-routes cookie behaviour on /auth/login", () => {
|
|
|
128
132
|
expect(typeof body.token).toBe("string");
|
|
129
133
|
expect(body.token.length).toBeGreaterThan(20);
|
|
130
134
|
});
|
|
135
|
+
|
|
136
|
+
test("cookie maxAge tracks the jwt helper's ttlSeconds, not a hardcoded default", async () => {
|
|
137
|
+
const customTtl = 60 * 60; // 1h — distinct from the 24h default
|
|
138
|
+
const { app } = await buildApp({}, undefined, customTtl);
|
|
139
|
+
const res = await app.request("/api/auth/login", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "Content-Type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ email: "a@b.c", password: "pw" }),
|
|
143
|
+
});
|
|
144
|
+
expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(new RegExp(`Max-Age=${customTtl}\\b`));
|
|
145
|
+
expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(new RegExp(`Max-Age=${customTtl}\\b`));
|
|
146
|
+
});
|
|
131
147
|
});
|
|
132
148
|
|
|
133
149
|
describe("auth-routes cookie behaviour on /auth/logout", () => {
|
|
@@ -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: [] };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
CSRF_COOKIE_NAME as DISPATCHER_CSRF_COOKIE_NAME,
|
|
4
|
+
CSRF_HEADER_NAME as DISPATCHER_CSRF_HEADER_NAME,
|
|
5
|
+
} from "@cosmicdrift/kumiko-dispatcher-live";
|
|
6
|
+
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "../auth-middleware";
|
|
7
|
+
|
|
8
|
+
// dispatcher-live keeps its own literal copies of these two constants
|
|
9
|
+
// (see packages/dispatcher-live/src/csrf.ts) because it must stay
|
|
10
|
+
// server-dep-free for browser/React Native bundles. This test is the
|
|
11
|
+
// guardrail that catches drift in CI instead of at runtime.
|
|
12
|
+
describe("CSRF constant sync between framework and dispatcher-live", () => {
|
|
13
|
+
test("cookie name matches", () => {
|
|
14
|
+
expect(DISPATCHER_CSRF_COOKIE_NAME).toBe(CSRF_COOKIE_NAME);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("header name matches", () => {
|
|
18
|
+
expect(DISPATCHER_CSRF_HEADER_NAME).toBe(CSRF_HEADER_NAME);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -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
|
});
|