@cosmicdrift/kumiko-framework 0.158.2 → 0.159.1
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__/auth-routes-cookie.test.ts +16 -1
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +4 -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/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +34 -2
- 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/pii-field-encryption.ts +49 -15
- 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 +305 -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/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +92 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- 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__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- 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-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 +1 -0
- package/src/engine/define-handler.ts +10 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-builder-state.ts +3 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +1 -1
- 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 +2 -2
- package/src/engine/ownership.ts +22 -76
- 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/define-handler.ts +94 -0
- package/src/engine/types/entity-handlers.ts +30 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +45 -0
- package/src/engine/types/fields.ts +19 -31
- package/src/engine/types/handlers.ts +7 -26
- package/src/engine/types/hooks.ts +1 -15
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +34 -9
- package/src/engine/types/ownership.ts +83 -0
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +0 -46
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/entrypoint/index.ts +2 -2
- 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/event-store.ts +28 -32
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/types.ts +22 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/i18n/required-surface-keys.ts +1 -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 +38 -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/dispatch-shared.ts +12 -2
- 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/system-hooks.ts +7 -0
- 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 +13 -39
- 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/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.159.1",
|
|
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.159.1",
|
|
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.159.1",
|
|
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
|
+
});
|
|
@@ -52,8 +52,11 @@ function createStubDispatcher(overrides?: Partial<Dispatcher>): Dispatcher {
|
|
|
52
52
|
async function buildApp(
|
|
53
53
|
overrides: Partial<AuthRoutesConfig> = {},
|
|
54
54
|
dispatcher: Dispatcher = createStubDispatcher(),
|
|
55
|
+
ttlSeconds?: number,
|
|
55
56
|
): Promise<{ app: Hono; validToken: string }> {
|
|
56
|
-
const jwt =
|
|
57
|
+
const jwt = ttlSeconds
|
|
58
|
+
? createJwtHelper(JWT_SECRET, undefined, ttlSeconds)
|
|
59
|
+
: createJwtHelper(JWT_SECRET);
|
|
57
60
|
const validToken = await jwt.sign(TestUsers.user);
|
|
58
61
|
const config: AuthRoutesConfig = {
|
|
59
62
|
membershipQuery: "tenant:query:memberships",
|
|
@@ -128,6 +131,18 @@ describe("auth-routes cookie behaviour on /auth/login", () => {
|
|
|
128
131
|
expect(typeof body.token).toBe("string");
|
|
129
132
|
expect(body.token.length).toBeGreaterThan(20);
|
|
130
133
|
});
|
|
134
|
+
|
|
135
|
+
test("cookie maxAge tracks the jwt helper's ttlSeconds, not a hardcoded default", async () => {
|
|
136
|
+
const customTtl = 60 * 60; // 1h — distinct from the 24h default
|
|
137
|
+
const { app } = await buildApp({}, undefined, customTtl);
|
|
138
|
+
const res = await app.request("/api/auth/login", {
|
|
139
|
+
method: "POST",
|
|
140
|
+
headers: { "Content-Type": "application/json" },
|
|
141
|
+
body: JSON.stringify({ email: "a@b.c", password: "pw" }),
|
|
142
|
+
});
|
|
143
|
+
expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(new RegExp(`Max-Age=${customTtl}\\b`));
|
|
144
|
+
expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(new RegExp(`Max-Age=${customTtl}\\b`));
|
|
145
|
+
});
|
|
131
146
|
});
|
|
132
147
|
|
|
133
148
|
describe("auth-routes cookie behaviour on /auth/logout", () => {
|
|
@@ -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
|
+
});
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import * as jose from "jose";
|
|
3
3
|
import type { SessionUser } from "../../engine/types";
|
|
4
|
-
import { createJwtHelper } from "../jwt";
|
|
4
|
+
import { createJwtHelper, loadJwtSecretOrKeyring } from "../jwt";
|
|
5
5
|
|
|
6
6
|
const SECRET = "test-secret-at-least-32-characters-long-aa";
|
|
7
7
|
const TENANT = "11111111-1111-4111-8111-111111111111";
|
|
@@ -83,3 +83,152 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
|
|
|
83
83
|
await expect(jwt.verify(token)).rejects.toThrow(/sub/);
|
|
84
84
|
});
|
|
85
85
|
});
|
|
86
|
+
|
|
87
|
+
describe("createJwtHelper — keyring form", () => {
|
|
88
|
+
it("sets kid in the protected header when signing from a keyring", async () => {
|
|
89
|
+
const jwt = createJwtHelper({ keys: { v1: SECRET }, signKid: "v1" });
|
|
90
|
+
const token = await jwt.sign(user);
|
|
91
|
+
expect(jose.decodeProtectedHeader(token).kid).toBe("v1");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("verifies a keyring-signed token against a string-form helper on the same secret", async () => {
|
|
95
|
+
const keyringSigner = createJwtHelper({ keys: { v1: SECRET }, signKid: "v1" });
|
|
96
|
+
const token = await keyringSigner.sign(user);
|
|
97
|
+
|
|
98
|
+
const stringVerifier = createJwtHelper(SECRET);
|
|
99
|
+
const payload = await stringVerifier.verify(token);
|
|
100
|
+
expect(payload.sub).toBe(user.id);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("omits kid when signing from a plain secret (string form)", async () => {
|
|
104
|
+
const jwt = createJwtHelper(SECRET);
|
|
105
|
+
const token = await jwt.sign(user);
|
|
106
|
+
expect(jose.decodeProtectedHeader(token).kid).toBeUndefined();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("verifies a token signed under an old kid after rotating signKid", async () => {
|
|
110
|
+
const OLD_SECRET = "old-secret-at-least-32-characters-long-aa";
|
|
111
|
+
const before = createJwtHelper({ keys: { v1: OLD_SECRET }, signKid: "v1" });
|
|
112
|
+
const token = await before.sign(user);
|
|
113
|
+
|
|
114
|
+
const after = createJwtHelper({ keys: { v1: OLD_SECRET, v2: SECRET }, signKid: "v2" });
|
|
115
|
+
const payload = await after.verify(token);
|
|
116
|
+
expect(payload.sub).toBe(user.id);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("verifies a legacy no-kid token against a multi-key keyring", async () => {
|
|
120
|
+
const legacy = createJwtHelper(SECRET);
|
|
121
|
+
const token = await legacy.sign(user);
|
|
122
|
+
|
|
123
|
+
const rotated = createJwtHelper({
|
|
124
|
+
keys: { v1: "unrelated-secret-32-characters-longg", v2: SECRET },
|
|
125
|
+
signKid: "v2",
|
|
126
|
+
});
|
|
127
|
+
const payload = await rotated.verify(token);
|
|
128
|
+
expect(payload.sub).toBe(user.id);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("rejects a token whose kid is not in the keyring", async () => {
|
|
132
|
+
const signer = createJwtHelper({ keys: { v1: SECRET }, signKid: "v1" });
|
|
133
|
+
const token = await signer.sign(user);
|
|
134
|
+
|
|
135
|
+
const verifier = createJwtHelper({ keys: { v2: SECRET }, signKid: "v2" });
|
|
136
|
+
await expect(verifier.verify(token)).rejects.toThrow(/kid/);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("throws at creation when signKid is not present in the keyring", () => {
|
|
140
|
+
expect(() => createJwtHelper({ keys: { v1: SECRET }, signKid: "v2" })).toThrow(/signKid/);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("loadJwtSecretOrKeyring", () => {
|
|
145
|
+
it("no JWT_SECRET_V<n> → falls back to plain JWT_SECRET", () => {
|
|
146
|
+
expect(loadJwtSecretOrKeyring({ JWT_SECRET: SECRET })).toBe(SECRET);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("neither JWT_SECRET_V<n> nor JWT_SECRET set → throws", () => {
|
|
150
|
+
expect(() => loadJwtSecretOrKeyring({})).toThrow(/JWT_SECRET not set/);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("JWT_SECRET_V1 + JWT_SECRET_CURRENT_VERSION=1 → single-key keyring, signKid v1", () => {
|
|
154
|
+
expect(
|
|
155
|
+
loadJwtSecretOrKeyring({
|
|
156
|
+
JWT_SECRET_V1: SECRET,
|
|
157
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
158
|
+
}),
|
|
159
|
+
).toEqual({ keys: { v1: SECRET }, signKid: "v1" });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("rotation: two versions, current-version picks the sign key, both stay verifiable", () => {
|
|
163
|
+
const OLD_SECRET = "old-secret-at-least-32-characters-long!!";
|
|
164
|
+
expect(
|
|
165
|
+
loadJwtSecretOrKeyring({
|
|
166
|
+
JWT_SECRET_V1: OLD_SECRET,
|
|
167
|
+
JWT_SECRET_V2: SECRET,
|
|
168
|
+
JWT_SECRET_CURRENT_VERSION: "2",
|
|
169
|
+
}),
|
|
170
|
+
).toEqual({ keys: { v1: OLD_SECRET, v2: SECRET }, signKid: "v2" });
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("JWT_SECRET_V<n> present but JWT_SECRET_CURRENT_VERSION missing → throws", () => {
|
|
174
|
+
expect(() => loadJwtSecretOrKeyring({ JWT_SECRET_V1: SECRET })).toThrow(
|
|
175
|
+
/JWT_SECRET_CURRENT_VERSION not set/,
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("JWT_SECRET_CURRENT_VERSION points at a version with no matching JWT_SECRET_V<n> → throws", () => {
|
|
180
|
+
expect(() =>
|
|
181
|
+
loadJwtSecretOrKeyring({
|
|
182
|
+
JWT_SECRET_V1: SECRET,
|
|
183
|
+
JWT_SECRET_CURRENT_VERSION: "2",
|
|
184
|
+
}),
|
|
185
|
+
).toThrow(/not present in the keyring/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("plain JWT_SECRET is ignored once a keyring is present", () => {
|
|
189
|
+
expect(
|
|
190
|
+
loadJwtSecretOrKeyring({
|
|
191
|
+
JWT_SECRET: "should-be-ignored-once-keyring-present-aa",
|
|
192
|
+
JWT_SECRET_V1: SECRET,
|
|
193
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
194
|
+
}),
|
|
195
|
+
).toEqual({ keys: { v1: SECRET }, signKid: "v1" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("plain JWT_SECRET under 32 chars → throws", () => {
|
|
199
|
+
expect(() => loadJwtSecretOrKeyring({ JWT_SECRET: "too-short" })).toThrow(
|
|
200
|
+
/JWT_SECRET must be ≥32 chars/,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("JWT_SECRET_V<n> under 32 chars → throws (bypasses the env-schema check otherwise)", () => {
|
|
205
|
+
expect(() =>
|
|
206
|
+
loadJwtSecretOrKeyring({
|
|
207
|
+
JWT_SECRET_V1: "too-short",
|
|
208
|
+
JWT_SECRET_CURRENT_VERSION: "1",
|
|
209
|
+
}),
|
|
210
|
+
).toThrow(/JWT_SECRET_V1 must be ≥32 chars/);
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("createJwtHelper — ttlSeconds", () => {
|
|
215
|
+
it("defaults to 24h", () => {
|
|
216
|
+
const jwt = createJwtHelper(SECRET);
|
|
217
|
+
expect(jwt.ttlSeconds).toBe(24 * 60 * 60);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("exposes a custom ttlSeconds", () => {
|
|
221
|
+
const jwt = createJwtHelper(SECRET, ISSUER, 60 * 60);
|
|
222
|
+
expect(jwt.ttlSeconds).toBe(60 * 60);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("signs a token whose exp - iat exactly equals ttlSeconds", async () => {
|
|
226
|
+
const ttlSeconds = 60 * 60;
|
|
227
|
+
const jwt = createJwtHelper(SECRET, ISSUER, ttlSeconds);
|
|
228
|
+
const token = await jwt.sign(user);
|
|
229
|
+
const claims = jose.decodeJwt(token);
|
|
230
|
+
expect(claims.exp).toBeDefined();
|
|
231
|
+
expect(claims.iat).toBeDefined();
|
|
232
|
+
expect(claims.exp! - claims.iat!).toBe(ttlSeconds);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { createRegistry } from "../../engine";
|
|
3
|
+
import { buildServer } from "../server";
|
|
4
|
+
|
|
5
|
+
const JWT_SECRET = "x".repeat(40);
|
|
6
|
+
const registry = createRegistry([]);
|
|
7
|
+
|
|
8
|
+
describe("buildServer — jwtTtl default depends on sessionChecker wiring", () => {
|
|
9
|
+
test("no auth config → stateless default (1h)", () => {
|
|
10
|
+
const { jwt } = buildServer({ registry, context: {}, jwtSecret: JWT_SECRET });
|
|
11
|
+
expect(jwt.ttlSeconds).toBe(60 * 60);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("auth without sessionChecker → stateless default (1h)", () => {
|
|
15
|
+
const { jwt } = buildServer({
|
|
16
|
+
registry,
|
|
17
|
+
context: {},
|
|
18
|
+
jwtSecret: JWT_SECRET,
|
|
19
|
+
auth: { membershipQuery: "unused:query" },
|
|
20
|
+
});
|
|
21
|
+
expect(jwt.ttlSeconds).toBe(60 * 60);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("auth with sessionChecker → session-backed default (24h)", () => {
|
|
25
|
+
const { jwt } = buildServer({
|
|
26
|
+
registry,
|
|
27
|
+
context: {},
|
|
28
|
+
jwtSecret: JWT_SECRET,
|
|
29
|
+
auth: {
|
|
30
|
+
membershipQuery: "unused:query",
|
|
31
|
+
sessionChecker: async () => "live",
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
expect(jwt.ttlSeconds).toBe(24 * 60 * 60);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("explicit jwtTtl wins regardless of sessionChecker wiring", () => {
|
|
38
|
+
const withChecker = buildServer({
|
|
39
|
+
registry,
|
|
40
|
+
context: {},
|
|
41
|
+
jwtSecret: JWT_SECRET,
|
|
42
|
+
jwtTtl: 42,
|
|
43
|
+
auth: {
|
|
44
|
+
membershipQuery: "unused:query",
|
|
45
|
+
sessionChecker: async () => "live",
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
expect(withChecker.jwt.ttlSeconds).toBe(42);
|
|
49
|
+
|
|
50
|
+
const withoutChecker = buildServer({
|
|
51
|
+
registry,
|
|
52
|
+
context: {},
|
|
53
|
+
jwtSecret: JWT_SECRET,
|
|
54
|
+
jwtTtl: 42,
|
|
55
|
+
});
|
|
56
|
+
expect(withoutChecker.jwt.ttlSeconds).toBe(42);
|
|
57
|
+
});
|
|
58
|
+
});
|
package/src/api/api-constants.ts
CHANGED
|
@@ -20,6 +20,8 @@ export const Routes = {
|
|
|
20
20
|
authResetPassword: "/auth/reset-password",
|
|
21
21
|
authRequestEmailVerification: "/auth/request-email-verification",
|
|
22
22
|
authVerifyEmail: "/auth/verify-email",
|
|
23
|
+
authRequestAccountUnlock: "/auth/request-account-unlock",
|
|
24
|
+
authConfirmAccountUnlock: "/auth/confirm-account-unlock",
|
|
23
25
|
authSignupRequest: "/auth/signup-request",
|
|
24
26
|
authSignupConfirm: "/auth/signup-confirm",
|
|
25
27
|
// Tenant-Invite (Magic-Link): 3 separate accept-Endpoints für klare
|
|
@@ -42,6 +44,8 @@ export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
|
|
|
42
44
|
`/api${Routes.authResetPassword}`,
|
|
43
45
|
`/api${Routes.authRequestEmailVerification}`,
|
|
44
46
|
`/api${Routes.authVerifyEmail}`,
|
|
47
|
+
`/api${Routes.authRequestAccountUnlock}`,
|
|
48
|
+
`/api${Routes.authConfirmAccountUnlock}`,
|
|
45
49
|
`/api${Routes.authSignupRequest}`,
|
|
46
50
|
`/api${Routes.authSignupConfirm}`,
|
|
47
51
|
// invite-accept braucht JWT (logged-in User, Branch 1) — NICHT public.
|
|
@@ -17,9 +17,9 @@ export const CSRF_COOKIE_NAME = "kumiko_csrf";
|
|
|
17
17
|
export const CSRF_HEADER_NAME = "X-CSRF-Token";
|
|
18
18
|
|
|
19
19
|
// Prefix that marks a bearer token as a long-lived Personal Access Token
|
|
20
|
-
// rather than a JWT session. The
|
|
21
|
-
//
|
|
22
|
-
// here so both sides import the same literal.
|
|
20
|
+
// rather than a JWT session. The personal-access-tokens feature mints tokens
|
|
21
|
+
// with this prefix and declares it as its tokenVerifier shape (kind:
|
|
22
|
+
// "prefix") — kept here so both sides import the same literal.
|
|
23
23
|
export const PAT_TOKEN_PREFIX = "kpat_";
|
|
24
24
|
|
|
25
25
|
// Which wire the current request authenticated over. Downstream
|
|
@@ -47,13 +47,13 @@ export type AuthSessionChecker = (
|
|
|
47
47
|
expectedUserId: string,
|
|
48
48
|
) => Promise<AuthSessionStatus>;
|
|
49
49
|
|
|
50
|
-
// Resolves a raw
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
export type
|
|
50
|
+
// Resolves a raw bearer token into a SessionUser, or null when no registered
|
|
51
|
+
// provider claims it (or the claiming provider rejects it as unknown/revoked/
|
|
52
|
+
// expired). Wired generically from the auth-foundation `tokenVerifier`
|
|
53
|
+
// extension-point registry — the middleware has no PAT/JWT-specific
|
|
54
|
+
// knowledge, it just consults whatever the app wired in and short-circuits
|
|
55
|
+
// the JWT path on a hit.
|
|
56
|
+
export type TokenVerifier = (rawToken: string) => Promise<SessionUser | null>;
|
|
57
57
|
|
|
58
58
|
export type TenantLifecycleStatusResolver = (
|
|
59
59
|
tenantId: TenantId,
|
|
@@ -64,15 +64,13 @@ export type AuthMiddlewareOptions = {
|
|
|
64
64
|
// reports anything other than "live", the request is rejected with 401.
|
|
65
65
|
// Omit to run in stateless-JWT mode (any valid JWT is accepted).
|
|
66
66
|
readonly sessionChecker?: AuthSessionChecker;
|
|
67
|
-
// Called for bearer tokens
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
// TTL. Has no effect when sessionChecker is undefined.
|
|
75
|
-
readonly strictMode?: boolean;
|
|
67
|
+
// Called for bearer tokens, BEFORE jwt.verify. On a hit the middleware
|
|
68
|
+
// sets the returned SessionUser and skips the JWT path entirely; on a
|
|
69
|
+
// miss (null) falls through to jwt.verify. Wired from the auth-foundation
|
|
70
|
+
// `tokenVerifier` extension-point registry — generic across providers
|
|
71
|
+
// (PAT, future JWT-provider, ...), the middleware itself has no
|
|
72
|
+
// provider-specific knowledge. Omit to disable non-JWT bearer auth.
|
|
73
|
+
readonly tokenVerifier?: TokenVerifier;
|
|
76
74
|
// Opt-in: when set, requests without a JWT are treated as anonymous
|
|
77
75
|
// callers instead of being rejected with 401. The middleware synthesises
|
|
78
76
|
// a SessionUser with id="anonymous" and roles=["anonymous"], scoped to a
|
|
@@ -222,13 +220,7 @@ function extractToken(
|
|
|
222
220
|
}
|
|
223
221
|
|
|
224
222
|
export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions = {}) {
|
|
225
|
-
const {
|
|
226
|
-
sessionChecker,
|
|
227
|
-
strictMode = false,
|
|
228
|
-
anonymousAccess,
|
|
229
|
-
patResolver,
|
|
230
|
-
resolveTenantLifecycleStatus,
|
|
231
|
-
} = options;
|
|
223
|
+
const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus } = options;
|
|
232
224
|
|
|
233
225
|
// Fail loud at boot, not silently at request time: a tenantResolver
|
|
234
226
|
// without a declared resolverTrust is an ambiguous trust decision no
|
|
@@ -282,11 +274,17 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
282
274
|
}
|
|
283
275
|
const { token, transport } = extracted;
|
|
284
276
|
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
277
|
+
// Generic bearer-verifier path: try the wired tokenVerifier (PAT, future
|
|
278
|
+
// JWT-provider, ...) BEFORE jwt.verify. A hit short-circuits the JWT path
|
|
279
|
+
// entirely; a miss (null — no provider's shape matched, or the matching
|
|
280
|
+
// provider rejected the token) falls through to the normal JWT flow below,
|
|
281
|
+
// which rejects it uniformly as invalid_token. Cookie transport is never
|
|
282
|
+
// routed here (the browser holds the JWT).
|
|
283
|
+
if (tokenVerifier && transport === "bearer") {
|
|
284
|
+
const verifiedUser = await tokenVerifier(token);
|
|
285
|
+
if (verifiedUser) {
|
|
286
|
+
return await handleVerifiedBearerUser(c, verifiedUser, next, resolveTenantLifecycleStatus);
|
|
287
|
+
}
|
|
290
288
|
}
|
|
291
289
|
|
|
292
290
|
let payload: Awaited<ReturnType<JwtHelper["verify"]>>;
|
|
@@ -302,15 +300,16 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
302
300
|
}
|
|
303
301
|
|
|
304
302
|
// Session liveness check — only when both a checker is wired AND the
|
|
305
|
-
// token carries a sid.
|
|
306
|
-
//
|
|
303
|
+
// token carries a sid.
|
|
304
|
+
// A checker wired without a sid on the token means the token predates
|
|
305
|
+
// session tracking (or the JWT was forged) — reject.
|
|
307
306
|
if (sessionChecker) {
|
|
308
307
|
if (payload.jti) {
|
|
309
308
|
const status = await sessionChecker(payload.jti, payload.sub);
|
|
310
309
|
if (status !== "live") {
|
|
311
310
|
return sessionInvalid(c, status);
|
|
312
311
|
}
|
|
313
|
-
} else
|
|
312
|
+
} else {
|
|
314
313
|
return sessionInvalid(c, "no_sid");
|
|
315
314
|
}
|
|
316
315
|
}
|
|
@@ -360,49 +359,39 @@ export function getAuthTransport(c: Context): AuthTransport | undefined {
|
|
|
360
359
|
return c.get(AUTH_TRANSPORT_KEY) as AuthTransport | undefined;
|
|
361
360
|
}
|
|
362
361
|
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
async function
|
|
362
|
+
// Verified-bearer request flow. `user` was already resolved by the wired
|
|
363
|
+
// tokenVerifier (PAT today, future JWT-provider). Apply the same
|
|
364
|
+
// X-Tenant-mismatch guard as the JWT path before continuing. Structured like
|
|
365
|
+
// handleAnonymous so authMiddleware stays flat.
|
|
366
|
+
async function handleVerifiedBearerUser(
|
|
368
367
|
c: Context,
|
|
369
|
-
|
|
370
|
-
token: string,
|
|
368
|
+
user: SessionUser,
|
|
371
369
|
next: Next,
|
|
372
370
|
resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
|
|
373
371
|
): Promise<Response | undefined> {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return middlewareReject(c, {
|
|
377
|
-
code: "invalid_token",
|
|
378
|
-
status: 401,
|
|
379
|
-
message: "personal access token invalid, revoked or expired",
|
|
380
|
-
i18nKey: "auth.errors.invalidToken",
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
// The PAT carries its own tenant; an X-Tenant header pointing elsewhere is a
|
|
384
|
-
// confused client — reject loudly, same stance as the JWT path.
|
|
372
|
+
// The token carries its own tenant; an X-Tenant header pointing elsewhere is
|
|
373
|
+
// a confused client — reject loudly, same stance as the JWT path.
|
|
385
374
|
const headerTenant = c.req.header(TENANT_HEADER_NAME);
|
|
386
|
-
if (headerTenant !== undefined && headerTenant !==
|
|
375
|
+
if (headerTenant !== undefined && headerTenant !== user.tenantId) {
|
|
387
376
|
return middlewareReject(c, {
|
|
388
377
|
code: "tenant_mismatch",
|
|
389
378
|
status: 400,
|
|
390
|
-
message: "
|
|
379
|
+
message: "token tenantId and X-Tenant header disagree",
|
|
391
380
|
i18nKey: "auth.errors.tenantMismatch",
|
|
392
|
-
details: {
|
|
381
|
+
details: { tokenTenantId: user.tenantId, headerTenantId: headerTenant },
|
|
393
382
|
});
|
|
394
383
|
}
|
|
395
|
-
c.set(USER_KEY,
|
|
384
|
+
c.set(USER_KEY, user);
|
|
396
385
|
c.set(AUTH_TRANSPORT_KEY, "bearer");
|
|
397
386
|
const lifecycleReject = await rejectIfTenantTeardown(
|
|
398
387
|
c,
|
|
399
|
-
|
|
388
|
+
user.tenantId,
|
|
400
389
|
resolveTenantLifecycleStatus,
|
|
401
390
|
);
|
|
402
391
|
if (lifecycleReject) return lifecycleReject;
|
|
403
392
|
await next();
|
|
404
|
-
// skip:
|
|
405
|
-
// Response|undefined union honest (same as handleAnonymous).
|
|
393
|
+
// skip: verified-bearer path completed — next() ran; explicit return keeps
|
|
394
|
+
// the Response|undefined union honest (same as handleAnonymous).
|
|
406
395
|
return;
|
|
407
396
|
}
|
|
408
397
|
|