@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.
Files changed (109) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +16 -1
  4. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  5. package/src/api/__tests__/jwt.test.ts +150 -1
  6. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  7. package/src/api/api-constants.ts +4 -0
  8. package/src/api/auth-middleware.ts +48 -59
  9. package/src/api/auth-routes.ts +51 -17
  10. package/src/api/index.ts +3 -3
  11. package/src/api/jwt.ts +148 -7
  12. package/src/api/pii-leak-guard.ts +5 -2
  13. package/src/api/server.ts +19 -5
  14. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  15. package/src/bun-db/query.ts +34 -2
  16. package/src/consumer-cli.ts +87 -0
  17. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  18. package/src/crypto/blind-index.ts +8 -4
  19. package/src/crypto/event-pii.ts +1 -0
  20. package/src/crypto/pii-field-encryption.ts +49 -15
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  22. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
  23. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  24. package/src/db/blind-index-cleanup.ts +3 -1
  25. package/src/db/connection.ts +3 -11
  26. package/src/db/encryption.ts +2 -3
  27. package/src/db/entity-table-meta-types.ts +92 -0
  28. package/src/db/entity-table-meta.ts +16 -90
  29. package/src/db/queries/backfill-pii.ts +1 -0
  30. package/src/db/queries/event-consumer.ts +35 -2
  31. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  32. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  33. package/src/engine/__tests__/define-roles.test.ts +21 -0
  34. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  35. package/src/engine/__tests__/store-table.test.ts +12 -0
  36. package/src/engine/boot-validator/action-wiring.ts +1 -1
  37. package/src/engine/boot-validator/boot-check.ts +21 -0
  38. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  39. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  40. package/src/engine/boot-validator/index.ts +3 -9
  41. package/src/engine/boot-validator/screens.ts +1 -1
  42. package/src/engine/define-feature.ts +1 -0
  43. package/src/engine/define-handler.ts +10 -91
  44. package/src/engine/entity-handlers.ts +15 -27
  45. package/src/engine/feature-builder-state.ts +3 -0
  46. package/src/engine/feature-config-events-jobs.ts +1 -1
  47. package/src/engine/feature-entity-handlers.ts +1 -1
  48. package/src/engine/feature-ui-extensions.ts +5 -1
  49. package/src/engine/field-helpers.ts +31 -0
  50. package/src/engine/handler-helpers.ts +26 -0
  51. package/src/engine/hook-helpers.ts +14 -0
  52. package/src/engine/index.ts +2 -2
  53. package/src/engine/ownership.ts +22 -76
  54. package/src/engine/registry-validate.ts +1 -1
  55. package/src/engine/screen-helpers.ts +54 -0
  56. package/src/engine/tier-resolver-extension.ts +3 -2
  57. package/src/engine/types/define-handler.ts +94 -0
  58. package/src/engine/types/entity-handlers.ts +30 -0
  59. package/src/engine/types/event-type-map.ts +1 -37
  60. package/src/engine/types/feature.ts +45 -0
  61. package/src/engine/types/fields.ts +19 -31
  62. package/src/engine/types/handlers.ts +7 -26
  63. package/src/engine/types/hooks.ts +1 -15
  64. package/src/engine/types/http-route.ts +1 -72
  65. package/src/engine/types/identifiers.ts +1 -47
  66. package/src/engine/types/index.ts +34 -9
  67. package/src/engine/types/ownership.ts +83 -0
  68. package/src/engine/types/relations.ts +1 -51
  69. package/src/engine/types/screen.ts +0 -46
  70. package/src/engine/types/target-ref.ts +1 -21
  71. package/src/engine/types/tree-node.ts +1 -129
  72. package/src/entrypoint/index.ts +2 -2
  73. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  74. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  75. package/src/event-store/event-store.ts +28 -32
  76. package/src/event-store/events-schema.ts +1 -10
  77. package/src/event-store/index.ts +3 -2
  78. package/src/event-store/types.ts +22 -0
  79. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  80. package/src/files/file-handle.ts +2 -19
  81. package/src/i18n/required-surface-keys.ts +1 -1
  82. package/src/logging/types.ts +1 -7
  83. package/src/observability/types/index.ts +1 -29
  84. package/src/observability/types/metric.ts +1 -56
  85. package/src/observability/types/provider.ts +1 -32
  86. package/src/observability/types/span.ts +1 -58
  87. package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
  88. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  89. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  90. package/src/pipeline/dispatch-shared.ts +12 -2
  91. package/src/pipeline/entity-cache.ts +2 -33
  92. package/src/pipeline/event-consumer-state.ts +28 -3
  93. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  94. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  95. package/src/pipeline/event-dispatcher.ts +27 -1
  96. package/src/pipeline/system-hooks.ts +7 -0
  97. package/src/search/types.ts +1 -39
  98. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  99. package/src/secrets/__tests__/envelope.test.ts +1 -1
  100. package/src/secrets/envelope-cipher.ts +13 -39
  101. package/src/stack/__tests__/event-collector.test.ts +42 -0
  102. package/src/testing/__tests__/late-bound.test.ts +25 -0
  103. package/src/testing/__tests__/wait-for.test.ts +53 -0
  104. package/src/testing/boot-validator-fixture.ts +1 -1
  105. package/src/testing/file-provider-contract.ts +84 -0
  106. package/src/testing/handler-context.ts +1 -1
  107. package/src/testing/index.ts +1 -0
  108. package/src/time/geo-tz.ts +1 -32
  109. 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.158.2",
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.158.2",
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 = createJwtHelper(JWT_SECRET);
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
+ });
@@ -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 PAT feature mints tokens with this prefix;
21
- // the middleware uses it to route to patResolver instead of jwt.verify. Kept
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 Personal Access Token (bearer, prefixed PAT_TOKEN_PREFIX)
51
- // into a SessionUser, or null when the token is unknown/revoked/expired. The
52
- // PAT feature owns the DB-backed implementation: hash the token, look up the
53
- // row, resolve the user's CURRENT roles live (not a snapshot), and expand the
54
- // token's granted scopes into `pat.allowedQns`. Middleware just consults it
55
- // and short-circuits the JWT path on a hit.
56
- export type PatResolver = (rawToken: string) => Promise<SessionUser | null>;
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 carrying the PAT prefix, BEFORE jwt.verify. On a
68
- // hit the middleware sets the returned SessionUser and skips the JWT path
69
- // entirely. Omit to disable PAT auth (bearer PATs then fail jwt.verify 401).
70
- readonly patResolver?: PatResolver;
71
- // When true, a JWT WITHOUT a sid is rejected. Leave false during rollout
72
- // so already-issued stateless JWTs keep working until they expire; flip
73
- // to true once the server has been emitting sid for longer than the JWT
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
- // PAT path: a bearer token carrying the PAT prefix is a long-lived
286
- // Personal Access Token, not a JWT. Short-circuit the JWT path entirely.
287
- // Cookie transport is never a PAT (the browser holds the JWT).
288
- if (patResolver && transport === "bearer" && token.startsWith(PAT_TOKEN_PREFIX)) {
289
- return await handlePat(c, patResolver, token, next, resolveTenantLifecycleStatus);
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. strictMode governs the no-sid case below so that
306
- // both old JWTs (no sid) and rolling-deploy gaps can be handled.
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 if (strictMode) {
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
- // PAT request flow. Resolve the hashed token SessionUser (live roles +
364
- // granted scopes), then apply the same X-Tenant-mismatch guard as the JWT
365
- // path before continuing. A null resolve is an invalid/revoked/expired token
366
- // → 401. Structured like handleAnonymous so authMiddleware stays flat.
367
- async function handlePat(
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
- patResolver: PatResolver,
370
- token: string,
368
+ user: SessionUser,
371
369
  next: Next,
372
370
  resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
373
371
  ): Promise<Response | undefined> {
374
- const patUser = await patResolver(token);
375
- if (!patUser) {
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 !== patUser.tenantId) {
375
+ if (headerTenant !== undefined && headerTenant !== user.tenantId) {
387
376
  return middlewareReject(c, {
388
377
  code: "tenant_mismatch",
389
378
  status: 400,
390
- message: "PAT tenantId and X-Tenant header disagree",
379
+ message: "token tenantId and X-Tenant header disagree",
391
380
  i18nKey: "auth.errors.tenantMismatch",
392
- details: { patTenantId: patUser.tenantId, headerTenantId: headerTenant },
381
+ details: { tokenTenantId: user.tenantId, headerTenantId: headerTenant },
393
382
  });
394
383
  }
395
- c.set(USER_KEY, patUser);
384
+ c.set(USER_KEY, user);
396
385
  c.set(AUTH_TRANSPORT_KEY, "bearer");
397
386
  const lifecycleReject = await rejectIfTenantTeardown(
398
387
  c,
399
- patUser.tenantId,
388
+ user.tenantId,
400
389
  resolveTenantLifecycleStatus,
401
390
  );
402
391
  if (lifecycleReject) return lifecycleReject;
403
392
  await next();
404
- // skip: PAT path completed — next() ran; explicit return keeps the
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