@cosmicdrift/kumiko-framework 0.157.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 (110) 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__/redis-login-rate-limiter.integration.test.ts +66 -0
  7. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  8. package/src/api/api-constants.ts +4 -0
  9. package/src/api/auth-middleware.ts +48 -59
  10. package/src/api/auth-routes.ts +83 -17
  11. package/src/api/index.ts +8 -4
  12. package/src/api/jwt.ts +148 -7
  13. package/src/api/pii-leak-guard.ts +5 -2
  14. package/src/api/server.ts +19 -5
  15. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  16. package/src/bun-db/query.ts +34 -2
  17. package/src/consumer-cli.ts +87 -0
  18. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  19. package/src/crypto/blind-index.ts +8 -4
  20. package/src/crypto/event-pii.ts +1 -0
  21. package/src/crypto/pii-field-encryption.ts +49 -15
  22. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +305 -0
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  25. package/src/db/blind-index-cleanup.ts +3 -1
  26. package/src/db/connection.ts +3 -11
  27. package/src/db/encryption.ts +2 -3
  28. package/src/db/entity-table-meta-types.ts +92 -0
  29. package/src/db/entity-table-meta.ts +16 -90
  30. package/src/db/queries/backfill-pii.ts +1 -0
  31. package/src/db/queries/event-consumer.ts +35 -2
  32. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  33. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  34. package/src/engine/__tests__/define-roles.test.ts +21 -0
  35. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  36. package/src/engine/__tests__/store-table.test.ts +12 -0
  37. package/src/engine/boot-validator/action-wiring.ts +1 -1
  38. package/src/engine/boot-validator/boot-check.ts +21 -0
  39. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  40. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  41. package/src/engine/boot-validator/index.ts +3 -9
  42. package/src/engine/boot-validator/screens.ts +1 -1
  43. package/src/engine/define-feature.ts +1 -0
  44. package/src/engine/define-handler.ts +10 -91
  45. package/src/engine/entity-handlers.ts +15 -27
  46. package/src/engine/feature-builder-state.ts +3 -0
  47. package/src/engine/feature-config-events-jobs.ts +1 -1
  48. package/src/engine/feature-entity-handlers.ts +1 -1
  49. package/src/engine/feature-ui-extensions.ts +5 -1
  50. package/src/engine/field-helpers.ts +31 -0
  51. package/src/engine/handler-helpers.ts +26 -0
  52. package/src/engine/hook-helpers.ts +14 -0
  53. package/src/engine/index.ts +2 -2
  54. package/src/engine/ownership.ts +22 -76
  55. package/src/engine/registry-validate.ts +1 -1
  56. package/src/engine/screen-helpers.ts +54 -0
  57. package/src/engine/tier-resolver-extension.ts +3 -2
  58. package/src/engine/types/define-handler.ts +94 -0
  59. package/src/engine/types/entity-handlers.ts +30 -0
  60. package/src/engine/types/event-type-map.ts +1 -37
  61. package/src/engine/types/feature.ts +45 -0
  62. package/src/engine/types/fields.ts +19 -31
  63. package/src/engine/types/handlers.ts +7 -26
  64. package/src/engine/types/hooks.ts +1 -15
  65. package/src/engine/types/http-route.ts +1 -72
  66. package/src/engine/types/identifiers.ts +1 -47
  67. package/src/engine/types/index.ts +34 -9
  68. package/src/engine/types/ownership.ts +83 -0
  69. package/src/engine/types/relations.ts +1 -51
  70. package/src/engine/types/screen.ts +0 -46
  71. package/src/engine/types/target-ref.ts +1 -21
  72. package/src/engine/types/tree-node.ts +1 -129
  73. package/src/entrypoint/index.ts +2 -2
  74. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  75. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  76. package/src/event-store/event-store.ts +28 -32
  77. package/src/event-store/events-schema.ts +1 -10
  78. package/src/event-store/index.ts +3 -2
  79. package/src/event-store/types.ts +22 -0
  80. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  81. package/src/files/file-handle.ts +2 -19
  82. package/src/i18n/required-surface-keys.ts +1 -1
  83. package/src/logging/types.ts +1 -7
  84. package/src/observability/types/index.ts +1 -29
  85. package/src/observability/types/metric.ts +1 -56
  86. package/src/observability/types/provider.ts +1 -32
  87. package/src/observability/types/span.ts +1 -58
  88. package/src/pipeline/__tests__/dispatcher.test.ts +38 -1
  89. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  90. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  91. package/src/pipeline/dispatch-shared.ts +12 -2
  92. package/src/pipeline/entity-cache.ts +2 -33
  93. package/src/pipeline/event-consumer-state.ts +28 -3
  94. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  95. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  96. package/src/pipeline/event-dispatcher.ts +27 -1
  97. package/src/pipeline/system-hooks.ts +7 -0
  98. package/src/search/types.ts +1 -39
  99. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  100. package/src/secrets/__tests__/envelope.test.ts +1 -1
  101. package/src/secrets/envelope-cipher.ts +13 -39
  102. package/src/stack/__tests__/event-collector.test.ts +42 -0
  103. package/src/testing/__tests__/late-bound.test.ts +25 -0
  104. package/src/testing/__tests__/wait-for.test.ts +53 -0
  105. package/src/testing/boot-validator-fixture.ts +1 -1
  106. package/src/testing/file-provider-contract.ts +84 -0
  107. package/src/testing/handler-context.ts +1 -1
  108. package/src/testing/index.ts +1 -0
  109. package/src/time/geo-tz.ts +1 -32
  110. 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.157.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.157.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,66 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { createTestRedis, type TestRedis } from "../../stack";
3
+ import { createRedisLoginRateLimiter } from "../auth-routes";
4
+
5
+ let testRedis: TestRedis;
6
+
7
+ beforeAll(async () => {
8
+ testRedis = await createTestRedis();
9
+ });
10
+
11
+ afterAll(async () => {
12
+ await testRedis.cleanup();
13
+ });
14
+
15
+ beforeEach(async () => {
16
+ await testRedis.flushNamespace();
17
+ });
18
+
19
+ describe("createRedisLoginRateLimiter", () => {
20
+ test("allows exactly maxAttempts checks, then blocks", async () => {
21
+ const limiter = createRedisLoginRateLimiter(testRedis.redis, 3, 60_000);
22
+
23
+ expect(await limiter.check("1.2.3.4|user@test.local")).toBe(true);
24
+ expect(await limiter.check("1.2.3.4|user@test.local")).toBe(true);
25
+ expect(await limiter.check("1.2.3.4|user@test.local")).toBe(true);
26
+ expect(await limiter.check("1.2.3.4|user@test.local")).toBe(false);
27
+ });
28
+
29
+ test("reset clears the counter for that key", async () => {
30
+ const limiter = createRedisLoginRateLimiter(testRedis.redis, 1, 60_000);
31
+
32
+ expect(await limiter.check("k")).toBe(true);
33
+ expect(await limiter.check("k")).toBe(false);
34
+
35
+ await limiter.reset("k");
36
+ expect(await limiter.check("k")).toBe(true);
37
+ });
38
+
39
+ test("buckets are independent per key", async () => {
40
+ const limiter = createRedisLoginRateLimiter(testRedis.redis, 1, 60_000);
41
+
42
+ expect(await limiter.check("a")).toBe(true);
43
+ expect(await limiter.check("b")).toBe(true);
44
+ expect(await limiter.check("a")).toBe(false);
45
+ expect(await limiter.check("b")).toBe(false);
46
+ });
47
+
48
+ test("namespace keeps two limiter instances from sharing a keyspace", async () => {
49
+ const login = createRedisLoginRateLimiter(testRedis.redis, 1, 60_000, "login");
50
+ const mfa = createRedisLoginRateLimiter(testRedis.redis, 1, 60_000, "mfa-verify");
51
+
52
+ expect(await login.check("1.2.3.4")).toBe(true);
53
+ expect(await mfa.check("1.2.3.4")).toBe(true);
54
+ });
55
+
56
+ test("counter resets once windowMs elapses", async () => {
57
+ const limiter = createRedisLoginRateLimiter(testRedis.redis, 1, 150);
58
+
59
+ expect(await limiter.check("k")).toBe(true);
60
+ expect(await limiter.check("k")).toBe(false);
61
+
62
+ await Bun.sleep(200);
63
+
64
+ expect(await limiter.check("k")).toBe(true);
65
+ });
66
+ });
@@ -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.