@cosmicdrift/kumiko-framework 0.158.2 → 0.160.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
@@ -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,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createInMemoryLoginRateLimiter } from "../auth-routes";
3
+
4
+ describe("createInMemoryLoginRateLimiter — sweep + cap", () => {
5
+ test("sweepExpired drops windows that already reset before accepting a new key", async () => {
6
+ // Tiny thresholds so the Map hits the sweep path without flooding.
7
+ const limiter = createInMemoryLoginRateLimiter(10, 50, {
8
+ maxEntries: 100,
9
+ sweepThreshold: 2,
10
+ });
11
+
12
+ expect(await limiter.check("a")).toBe(true);
13
+ expect(await limiter.check("b")).toBe(true);
14
+ // Wait for both windows to expire, then a third check must sweep a+b
15
+ // (hits.size >= sweepThreshold) before inserting "c".
16
+ await Bun.sleep(60);
17
+ expect(await limiter.check("c")).toBe(true);
18
+ // Fresh window for "a" after sweep — not rate-limited.
19
+ expect(await limiter.check("a")).toBe(true);
20
+ });
21
+
22
+ test("enforceCap drops oldest entries when the map exceeds maxEntries", async () => {
23
+ const limiter = createInMemoryLoginRateLimiter(100, 60_000, {
24
+ maxEntries: 3,
25
+ sweepThreshold: 10_000, // never sweep — only the hard cap matters
26
+ });
27
+
28
+ expect(await limiter.check("k1")).toBe(true);
29
+ expect(await limiter.check("k2")).toBe(true);
30
+ expect(await limiter.check("k3")).toBe(true);
31
+ // 4th insert trips enforceCap → drops oldest (k1).
32
+ expect(await limiter.check("k4")).toBe(true);
33
+
34
+ // k1 was dropped — a fresh check starts a new window (allowed).
35
+ expect(await limiter.check("k1")).toBe(true);
36
+ // k2/k3/k4 still live in the map (cap=3 after k1 drop + k1 reinsert may
37
+ // drop another). Reset proves the API still works for survivors.
38
+ await limiter.reset("k4");
39
+ expect(await limiter.check("k4")).toBe(true);
40
+ });
41
+ });
@@ -0,0 +1,71 @@
1
+ // buildServer boot-time guards + httpRoute verb wiring (PUT branch).
2
+
3
+ import { describe, expect, test } from "bun:test";
4
+ import {
5
+ createEntity,
6
+ createFileField,
7
+ createRegistry,
8
+ createTextField,
9
+ defineFeature,
10
+ } from "../../engine";
11
+ import { buildServer } from "../server";
12
+
13
+ const JWT_SECRET = "server-boot-guards-test-secret-min-32-chars";
14
+
15
+ describe("buildServer — file-storage provider guard", () => {
16
+ const fileFieldFeature = defineFeature("needs-files", (r) => {
17
+ r.entity(
18
+ "doc",
19
+ createEntity({
20
+ table: "boot_guard_docs",
21
+ fields: { title: createTextField(), attachment: createFileField() },
22
+ }),
23
+ );
24
+ });
25
+
26
+ test("throws when registry declares file fields but no provider is mounted", () => {
27
+ expect(() =>
28
+ buildServer({
29
+ registry: createRegistry([fileFieldFeature]),
30
+ context: {},
31
+ jwtSecret: JWT_SECRET,
32
+ }),
33
+ ).toThrow(/no file-storage provider is mounted/);
34
+ });
35
+ });
36
+
37
+ describe("buildServer — rateLimit resolver guard", () => {
38
+ test("throws when L1 global middleware requested without resolver", () => {
39
+ expect(() =>
40
+ buildServer({
41
+ registry: createRegistry([]),
42
+ context: {},
43
+ jwtSecret: JWT_SECRET,
44
+ rateLimit: { global: { limit: 100, windowSeconds: 60 } },
45
+ }),
46
+ ).toThrow(/rateLimit middleware requested but no resolver available/);
47
+ });
48
+ });
49
+
50
+ describe("buildServer — feature httpRoute PUT mounting", () => {
51
+ const putFeature = defineFeature("put-route", (r) => {
52
+ r.httpRoute({
53
+ method: "PUT",
54
+ path: "/resource/42",
55
+ anonymous: true,
56
+ handler: (c) => c.json({ method: "PUT", ok: true }),
57
+ });
58
+ });
59
+
60
+ const { app } = buildServer({
61
+ registry: createRegistry([putFeature]),
62
+ context: {},
63
+ jwtSecret: JWT_SECRET,
64
+ });
65
+
66
+ test("PUT /resource/42 reaches the declared handler", async () => {
67
+ const res = await app.request("/resource/42", { method: "PUT" });
68
+ expect(res.status).toBe(200);
69
+ expect(await res.json()).toEqual({ method: "PUT", ok: true });
70
+ });
71
+ });
@@ -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
+ });
@@ -10,6 +10,7 @@ export const Routes = {
10
10
  query: "/query",
11
11
  command: "/command",
12
12
  sse: "/sse",
13
+ stream: "/stream",
13
14
  auth: "/auth",
14
15
  authLogin: "/auth/login",
15
16
  authMfaVerify: "/auth/mfa/verify",
@@ -20,6 +21,8 @@ export const Routes = {
20
21
  authResetPassword: "/auth/reset-password",
21
22
  authRequestEmailVerification: "/auth/request-email-verification",
22
23
  authVerifyEmail: "/auth/verify-email",
24
+ authRequestAccountUnlock: "/auth/request-account-unlock",
25
+ authConfirmAccountUnlock: "/auth/confirm-account-unlock",
23
26
  authSignupRequest: "/auth/signup-request",
24
27
  authSignupConfirm: "/auth/signup-confirm",
25
28
  // Tenant-Invite (Magic-Link): 3 separate accept-Endpoints für klare
@@ -42,6 +45,8 @@ export const PUBLIC_API_PATHS: ReadonlySet<string> = new Set([
42
45
  `/api${Routes.authResetPassword}`,
43
46
  `/api${Routes.authRequestEmailVerification}`,
44
47
  `/api${Routes.authVerifyEmail}`,
48
+ `/api${Routes.authRequestAccountUnlock}`,
49
+ `/api${Routes.authConfirmAccountUnlock}`,
45
50
  `/api${Routes.authSignupRequest}`,
46
51
  `/api${Routes.authSignupConfirm}`,
47
52
  // 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
 
@@ -16,17 +16,11 @@ import {
16
16
  type AuthSessionStatus,
17
17
  CSRF_COOKIE_NAME,
18
18
  getUser,
19
- type PatResolver,
19
+ type TokenVerifier,
20
20
  } from "./auth-middleware";
21
21
  import type { JwtHelper } from "./jwt";
22
22
  import { generateToken } from "./tokens";
23
23
 
24
- // Cookie lifetime must track the JWT's exp claim — both are issued together,
25
- // both reference the same session. jwt.ts's createJwtHelper hardcodes
26
- // setExpirationTime("24h"); if that ever becomes configurable this constant
27
- // follows it.
28
- const JWT_TTL_SECONDS = 24 * 60 * 60;
29
-
30
24
  // Resolves the Secure cookie flag. Locked off in dev/test so Playwright
31
25
  // against http://localhost:… can actually receive the cookie. Production
32
26
  // flips it on — browsers drop Secure cookies on http, so a misconfigured
@@ -48,6 +42,10 @@ function setAuthCookies(
48
42
  csrfToken: string;
49
43
  sameSite: "lax" | "strict";
50
44
  domain?: string | undefined;
45
+ // Cookie lifetime must track the JWT's exp claim — both are issued
46
+ // together, both reference the same session. Callers pass jwt.ttlSeconds
47
+ // so the two never drift apart.
48
+ ttlSeconds: number;
51
49
  },
52
50
  ): void {
53
51
  const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
@@ -55,7 +53,7 @@ function setAuthCookies(
55
53
  secure: cookieSecure(),
56
54
  sameSite,
57
55
  path: "/",
58
- maxAge: JWT_TTL_SECONDS,
56
+ maxAge: opts.ttlSeconds,
59
57
  ...(opts.domain !== undefined && { domain: opts.domain }),
60
58
  } as const;
61
59
 
@@ -262,14 +260,11 @@ export type AuthRoutesConfig = {
262
260
  // at login, check it here on every request. Leaving this empty disables
263
261
  // the revocation path — old JWTs stay valid until they expire naturally.
264
262
  sessionChecker?: SessionChecker;
265
- // When true, a JWT WITHOUT a sid is rejected. Use during deploy-rollouts
266
- // once all fresh JWTs emit a sid and the legacy stateless tokens are
267
- // expected to have expired. Default false keeps old tokens working.
268
- sessionStrictMode?: boolean;
269
- // Resolves bearer Personal Access Tokens (PAT_TOKEN_PREFIX) into a
270
- // SessionUser, consulted BEFORE jwt.verify. Wired by the
271
- // personal-access-tokens feature; unwired = PAT auth disabled.
272
- patResolver?: PatResolver;
263
+ // Resolves bearer tokens (any registered auth-foundation `tokenVerifier`
264
+ // provider PAT today, future JWT-provider), consulted BEFORE jwt.verify.
265
+ // Wired by run-prod-app/run-dev-app when at least one provider feature is
266
+ // mounted; unwired = no non-JWT bearer auth.
267
+ tokenVerifier?: TokenVerifier;
273
268
  // Per-token request-rate limiter for PAT-authenticated requests, keyed by
274
269
  // the token id (SessionUser.pat.tokenId). Cookie/JWT requests are unaffected.
275
270
  // Reuses the LoginRateLimiter shape (a generic keyed check/reset limiter).
@@ -288,6 +283,12 @@ export type AuthRoutesConfig = {
288
283
  passwordReset?: PasswordResetConfig;
289
284
  // Email-verification flow. Symmetric to passwordReset.
290
285
  emailVerification?: EmailVerificationConfig;
286
+ // Account-unlock flow (#1266). When wired, POST
287
+ // /auth/request-account-unlock + /auth/confirm-account-unlock are
288
+ // mounted as public routes. Confirm needs no extra body field
289
+ // (token-only, like email-verification) — the handler only clears the
290
+ // Redis lockout state, no entity write.
291
+ accountUnlock?: AccountUnlockConfig;
291
292
  // Self-Signup (Magic-Link). Wenn wired, mountet POST
292
293
  // /auth/signup-request + /auth/signup-confirm. Confirm returnt JWT-
293
294
  // Cookie + Session-Body wie login.
@@ -358,6 +359,13 @@ export type EmailVerificationConfig = {
358
359
  confirmHandler: string;
359
360
  };
360
361
 
362
+ export type AccountUnlockConfig = {
363
+ requestHandler: string;
364
+ // Token-only body (mirrors EmailVerificationConfig) — no entity write, so
365
+ // there's no newPassword-equivalent field.
366
+ confirmHandler: string;
367
+ };
368
+
361
369
  // Tenant-Invite Magic-Link. Drei Accept-Branches für klare Separation:
362
370
  // - acceptHandler: logged-in User akzeptiert via JWT (Branch 1)
363
371
  // - acceptWithLoginHandler: anon User mit existing email (Branch 2)
@@ -518,7 +526,13 @@ export function createAuthRoutes(
518
526
  }
519
527
  const token = await jwt.sign(sessionForJwt);
520
528
  const csrfToken = generateToken();
521
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
529
+ setAuthCookies(c, {
530
+ token,
531
+ csrfToken,
532
+ sameSite: cookieSameSite,
533
+ domain: cookieDomain,
534
+ ttlSeconds: jwt.ttlSeconds,
535
+ });
522
536
  return token;
523
537
  }
524
538
 
@@ -729,6 +743,26 @@ export function createAuthRoutes(
729
743
  });
730
744
  }
731
745
 
746
+ // Account-unlock mirrors email-verification (token-only confirm body) —
747
+ // clears the Redis lockout state instead of an entity field, see
748
+ // confirm-account-unlock.write.ts.
749
+ if (config.accountUnlock) {
750
+ const au = config.accountUnlock;
751
+ registerTokenRequestRoute({
752
+ api,
753
+ dispatcher,
754
+ path: Routes.authRequestAccountUnlock,
755
+ requestHandler: au.requestHandler,
756
+ });
757
+ registerTokenConfirmRoute({
758
+ api,
759
+ dispatcher,
760
+ path: Routes.authConfirmAccountUnlock,
761
+ confirmHandler: au.confirmHandler,
762
+ schema: VerifyEmailBody,
763
+ });
764
+ }
765
+
732
766
  // Self-Signup (Magic-Link). Request mountet wie reset/verify den
733
767
  // silent-success-Pfad mit Token-Mail. Confirm ist anders: returnt
734
768
  // SessionUser → die Route mintet JWT + setzt Cookies (Auto-Login