@cosmicdrift/kumiko-framework 0.158.2 → 0.160.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -2
- package/src/__tests__/consumer-cli.integration.test.ts +110 -0
- package/src/api/__tests__/api.test.ts +65 -0
- package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
- package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
- package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
- package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
- package/src/api/__tests__/jwt.test.ts +150 -1
- package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
- package/src/api/__tests__/server-boot-guards.test.ts +71 -0
- package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
- package/src/api/api-constants.ts +5 -0
- package/src/api/auth-middleware.ts +48 -59
- package/src/api/auth-routes.ts +51 -17
- package/src/api/index.ts +3 -3
- package/src/api/jwt.ts +148 -7
- package/src/api/pii-leak-guard.ts +5 -2
- package/src/api/routes.ts +57 -0
- package/src/api/server.ts +19 -5
- package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
- package/src/bun-db/query.ts +46 -27
- package/src/consumer-cli.ts +87 -0
- package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
- package/src/crypto/blind-index.ts +8 -4
- package/src/crypto/event-pii.ts +1 -0
- package/src/crypto/kms-adapter.ts +2 -118
- package/src/crypto/pii-field-encryption.ts +49 -15
- package/src/db/__tests__/build-filter-where.test.ts +34 -0
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
- package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
- package/src/db/blind-index-cleanup.ts +3 -1
- package/src/db/connection.ts +3 -11
- package/src/db/cursor.ts +1 -18
- package/src/db/dialect.ts +8 -19
- package/src/db/encryption.ts +2 -3
- package/src/db/entity-table-meta-types.ts +2 -0
- package/src/db/entity-table-meta.ts +16 -90
- package/src/db/event-store-executor.ts +4 -96
- package/src/db/queries/backfill-pii.ts +1 -0
- package/src/db/queries/event-consumer.ts +35 -2
- package/src/db/table-builder.ts +2 -19
- package/src/db/tenant-db.ts +6 -55
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
- package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
- package/src/engine/__tests__/boot-validator.test.ts +46 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
- package/src/engine/__tests__/define-roles.test.ts +21 -0
- package/src/engine/__tests__/engine.test.ts +28 -0
- package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
- package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
- package/src/engine/__tests__/registry.test.ts +40 -0
- package/src/engine/__tests__/store-table.test.ts +12 -0
- package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
- package/src/engine/boot-validator/action-wiring.ts +1 -1
- package/src/engine/boot-validator/boot-check.ts +21 -0
- package/src/engine/boot-validator/entity-handler.ts +10 -1
- package/src/engine/boot-validator/entity-list-screens.ts +1 -1
- package/src/engine/boot-validator/gdpr-storage.ts +0 -112
- package/src/engine/boot-validator/index.ts +3 -9
- package/src/engine/boot-validator/screens.ts +1 -1
- package/src/engine/define-feature.ts +2 -0
- package/src/engine/define-handler.ts +11 -91
- package/src/engine/entity-handlers.ts +15 -27
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
- package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
- package/src/engine/feature-ast/extractors/handlers.ts +19 -2
- package/src/engine/feature-ast/extractors/index.ts +1 -0
- package/src/engine/feature-ast/index.ts +2 -0
- package/src/engine/feature-ast/parse.ts +3 -0
- package/src/engine/feature-ast/patch.ts +2 -0
- package/src/engine/feature-ast/patcher.ts +21 -0
- package/src/engine/feature-ast/patterns.ts +16 -0
- package/src/engine/feature-ast/render.ts +15 -0
- package/src/engine/feature-builder-state.ts +6 -0
- package/src/engine/feature-config-events-jobs.ts +1 -1
- package/src/engine/feature-entity-handlers.ts +36 -2
- package/src/engine/feature-ui-extensions.ts +5 -1
- package/src/engine/field-helpers.ts +31 -0
- package/src/engine/handler-helpers.ts +26 -0
- package/src/engine/hook-helpers.ts +14 -0
- package/src/engine/index.ts +5 -2
- package/src/engine/ownership.ts +22 -76
- package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
- package/src/engine/pattern-library/library.ts +2 -0
- package/src/engine/pattern-library/mixed-schemas.ts +37 -0
- package/src/engine/registry-facade.ts +9 -0
- package/src/engine/registry-ingest.ts +10 -0
- package/src/engine/registry-state.ts +3 -0
- package/src/engine/registry-validate.ts +1 -1
- package/src/engine/screen-helpers.ts +54 -0
- package/src/engine/tier-resolver-extension.ts +3 -2
- package/src/engine/types/config.ts +2 -497
- package/src/engine/types/define-handler.ts +2 -0
- package/src/engine/types/entity-handlers.ts +2 -0
- package/src/engine/types/event-type-map.ts +1 -37
- package/src/engine/types/feature.ts +2 -976
- package/src/engine/types/fields.ts +2 -697
- package/src/engine/types/handlers.ts +2 -839
- package/src/engine/types/hooks.ts +2 -184
- package/src/engine/types/http-route.ts +1 -72
- package/src/engine/types/identifiers.ts +1 -47
- package/src/engine/types/index.ts +66 -33
- package/src/engine/types/nav.ts +2 -67
- package/src/engine/types/ownership.ts +2 -0
- package/src/engine/types/projection.ts +2 -165
- package/src/engine/types/relations.ts +1 -51
- package/src/engine/types/screen.ts +2 -793
- package/src/engine/types/step.ts +2 -334
- package/src/engine/types/target-ref.ts +1 -21
- package/src/engine/types/tree-node.ts +1 -129
- package/src/engine/types/workspace.ts +2 -42
- package/src/entrypoint/index.ts +2 -2
- package/src/errors/write-error-info.ts +6 -22
- package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
- package/src/event-store/errors.ts +2 -35
- package/src/event-store/event-store.ts +28 -51
- package/src/event-store/events-schema.ts +1 -10
- package/src/event-store/index.ts +3 -2
- package/src/event-store/snapshot.ts +11 -35
- package/src/event-store/types.ts +2 -0
- package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
- package/src/files/file-handle.ts +2 -19
- package/src/files/provider-resolver.ts +3 -5
- package/src/files/types.ts +5 -54
- package/src/i18n/required-surface-keys.ts +1 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
- package/src/logging/types.ts +1 -7
- package/src/observability/types/index.ts +1 -29
- package/src/observability/types/metric.ts +1 -56
- package/src/observability/types/provider.ts +1 -32
- package/src/observability/types/span.ts +1 -58
- package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
- package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
- package/src/pipeline/dispatch-shared.ts +51 -3
- package/src/pipeline/dispatch-stream.ts +74 -0
- package/src/pipeline/dispatcher-utils.ts +1 -1
- package/src/pipeline/dispatcher.ts +7 -0
- package/src/pipeline/entity-cache.ts +2 -33
- package/src/pipeline/event-consumer-state.ts +28 -3
- package/src/pipeline/event-dispatcher-admin.ts +4 -0
- package/src/pipeline/event-dispatcher-delivery.ts +29 -3
- package/src/pipeline/event-dispatcher.ts +27 -1
- package/src/pipeline/multi-stream-apply-context.ts +4 -42
- package/src/pipeline/system-hooks.ts +7 -0
- package/src/rate-limit/resolver.ts +10 -30
- package/src/search/types.ts +1 -39
- package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
- package/src/secrets/__tests__/envelope.test.ts +1 -1
- package/src/secrets/envelope-cipher.ts +17 -45
- package/src/secrets/types.ts +2 -177
- package/src/stack/__tests__/event-collector.test.ts +42 -0
- package/src/testing/__tests__/late-bound.test.ts +25 -0
- package/src/testing/__tests__/wait-for.test.ts +53 -0
- package/src/testing/boot-validator-fixture.ts +1 -1
- package/src/testing/file-provider-contract.ts +84 -0
- package/src/testing/handler-context.ts +1 -1
- package/src/testing/index.ts +1 -0
- package/src/time/geo-tz.ts +1 -32
- package/src/time/tz-context.ts +9 -56
- package/src/ui-types/index.ts +7 -7
|
@@ -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
|
+
});
|
package/src/api/api-constants.ts
CHANGED
|
@@ -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
|
|
21
|
-
//
|
|
22
|
-
// here so both sides import the same literal.
|
|
20
|
+
// rather than a JWT session. The personal-access-tokens feature mints tokens
|
|
21
|
+
// with this prefix and declares it as its tokenVerifier shape (kind:
|
|
22
|
+
// "prefix") — kept here so both sides import the same literal.
|
|
23
23
|
export const PAT_TOKEN_PREFIX = "kpat_";
|
|
24
24
|
|
|
25
25
|
// Which wire the current request authenticated over. Downstream
|
|
@@ -47,13 +47,13 @@ export type AuthSessionChecker = (
|
|
|
47
47
|
expectedUserId: string,
|
|
48
48
|
) => Promise<AuthSessionStatus>;
|
|
49
49
|
|
|
50
|
-
// Resolves a raw
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
export type
|
|
50
|
+
// Resolves a raw bearer token into a SessionUser, or null when no registered
|
|
51
|
+
// provider claims it (or the claiming provider rejects it as unknown/revoked/
|
|
52
|
+
// expired). Wired generically from the auth-foundation `tokenVerifier`
|
|
53
|
+
// extension-point registry — the middleware has no PAT/JWT-specific
|
|
54
|
+
// knowledge, it just consults whatever the app wired in and short-circuits
|
|
55
|
+
// the JWT path on a hit.
|
|
56
|
+
export type TokenVerifier = (rawToken: string) => Promise<SessionUser | null>;
|
|
57
57
|
|
|
58
58
|
export type TenantLifecycleStatusResolver = (
|
|
59
59
|
tenantId: TenantId,
|
|
@@ -64,15 +64,13 @@ export type AuthMiddlewareOptions = {
|
|
|
64
64
|
// reports anything other than "live", the request is rejected with 401.
|
|
65
65
|
// Omit to run in stateless-JWT mode (any valid JWT is accepted).
|
|
66
66
|
readonly sessionChecker?: AuthSessionChecker;
|
|
67
|
-
// Called for bearer tokens
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
// TTL. Has no effect when sessionChecker is undefined.
|
|
75
|
-
readonly strictMode?: boolean;
|
|
67
|
+
// Called for bearer tokens, BEFORE jwt.verify. On a hit the middleware
|
|
68
|
+
// sets the returned SessionUser and skips the JWT path entirely; on a
|
|
69
|
+
// miss (null) falls through to jwt.verify. Wired from the auth-foundation
|
|
70
|
+
// `tokenVerifier` extension-point registry — generic across providers
|
|
71
|
+
// (PAT, future JWT-provider, ...), the middleware itself has no
|
|
72
|
+
// provider-specific knowledge. Omit to disable non-JWT bearer auth.
|
|
73
|
+
readonly tokenVerifier?: TokenVerifier;
|
|
76
74
|
// Opt-in: when set, requests without a JWT are treated as anonymous
|
|
77
75
|
// callers instead of being rejected with 401. The middleware synthesises
|
|
78
76
|
// a SessionUser with id="anonymous" and roles=["anonymous"], scoped to a
|
|
@@ -222,13 +220,7 @@ function extractToken(
|
|
|
222
220
|
}
|
|
223
221
|
|
|
224
222
|
export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions = {}) {
|
|
225
|
-
const {
|
|
226
|
-
sessionChecker,
|
|
227
|
-
strictMode = false,
|
|
228
|
-
anonymousAccess,
|
|
229
|
-
patResolver,
|
|
230
|
-
resolveTenantLifecycleStatus,
|
|
231
|
-
} = options;
|
|
223
|
+
const { sessionChecker, anonymousAccess, tokenVerifier, resolveTenantLifecycleStatus } = options;
|
|
232
224
|
|
|
233
225
|
// Fail loud at boot, not silently at request time: a tenantResolver
|
|
234
226
|
// without a declared resolverTrust is an ambiguous trust decision no
|
|
@@ -282,11 +274,17 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
282
274
|
}
|
|
283
275
|
const { token, transport } = extracted;
|
|
284
276
|
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
277
|
+
// Generic bearer-verifier path: try the wired tokenVerifier (PAT, future
|
|
278
|
+
// JWT-provider, ...) BEFORE jwt.verify. A hit short-circuits the JWT path
|
|
279
|
+
// entirely; a miss (null — no provider's shape matched, or the matching
|
|
280
|
+
// provider rejected the token) falls through to the normal JWT flow below,
|
|
281
|
+
// which rejects it uniformly as invalid_token. Cookie transport is never
|
|
282
|
+
// routed here (the browser holds the JWT).
|
|
283
|
+
if (tokenVerifier && transport === "bearer") {
|
|
284
|
+
const verifiedUser = await tokenVerifier(token);
|
|
285
|
+
if (verifiedUser) {
|
|
286
|
+
return await handleVerifiedBearerUser(c, verifiedUser, next, resolveTenantLifecycleStatus);
|
|
287
|
+
}
|
|
290
288
|
}
|
|
291
289
|
|
|
292
290
|
let payload: Awaited<ReturnType<JwtHelper["verify"]>>;
|
|
@@ -302,15 +300,16 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
302
300
|
}
|
|
303
301
|
|
|
304
302
|
// Session liveness check — only when both a checker is wired AND the
|
|
305
|
-
// token carries a sid.
|
|
306
|
-
//
|
|
303
|
+
// token carries a sid.
|
|
304
|
+
// A checker wired without a sid on the token means the token predates
|
|
305
|
+
// session tracking (or the JWT was forged) — reject.
|
|
307
306
|
if (sessionChecker) {
|
|
308
307
|
if (payload.jti) {
|
|
309
308
|
const status = await sessionChecker(payload.jti, payload.sub);
|
|
310
309
|
if (status !== "live") {
|
|
311
310
|
return sessionInvalid(c, status);
|
|
312
311
|
}
|
|
313
|
-
} else
|
|
312
|
+
} else {
|
|
314
313
|
return sessionInvalid(c, "no_sid");
|
|
315
314
|
}
|
|
316
315
|
}
|
|
@@ -360,49 +359,39 @@ export function getAuthTransport(c: Context): AuthTransport | undefined {
|
|
|
360
359
|
return c.get(AUTH_TRANSPORT_KEY) as AuthTransport | undefined;
|
|
361
360
|
}
|
|
362
361
|
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
//
|
|
367
|
-
async function
|
|
362
|
+
// Verified-bearer request flow. `user` was already resolved by the wired
|
|
363
|
+
// tokenVerifier (PAT today, future JWT-provider). Apply the same
|
|
364
|
+
// X-Tenant-mismatch guard as the JWT path before continuing. Structured like
|
|
365
|
+
// handleAnonymous so authMiddleware stays flat.
|
|
366
|
+
async function handleVerifiedBearerUser(
|
|
368
367
|
c: Context,
|
|
369
|
-
|
|
370
|
-
token: string,
|
|
368
|
+
user: SessionUser,
|
|
371
369
|
next: Next,
|
|
372
370
|
resolveTenantLifecycleStatus?: TenantLifecycleStatusResolver,
|
|
373
371
|
): Promise<Response | undefined> {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return middlewareReject(c, {
|
|
377
|
-
code: "invalid_token",
|
|
378
|
-
status: 401,
|
|
379
|
-
message: "personal access token invalid, revoked or expired",
|
|
380
|
-
i18nKey: "auth.errors.invalidToken",
|
|
381
|
-
});
|
|
382
|
-
}
|
|
383
|
-
// The PAT carries its own tenant; an X-Tenant header pointing elsewhere is a
|
|
384
|
-
// confused client — reject loudly, same stance as the JWT path.
|
|
372
|
+
// The token carries its own tenant; an X-Tenant header pointing elsewhere is
|
|
373
|
+
// a confused client — reject loudly, same stance as the JWT path.
|
|
385
374
|
const headerTenant = c.req.header(TENANT_HEADER_NAME);
|
|
386
|
-
if (headerTenant !== undefined && headerTenant !==
|
|
375
|
+
if (headerTenant !== undefined && headerTenant !== user.tenantId) {
|
|
387
376
|
return middlewareReject(c, {
|
|
388
377
|
code: "tenant_mismatch",
|
|
389
378
|
status: 400,
|
|
390
|
-
message: "
|
|
379
|
+
message: "token tenantId and X-Tenant header disagree",
|
|
391
380
|
i18nKey: "auth.errors.tenantMismatch",
|
|
392
|
-
details: {
|
|
381
|
+
details: { tokenTenantId: user.tenantId, headerTenantId: headerTenant },
|
|
393
382
|
});
|
|
394
383
|
}
|
|
395
|
-
c.set(USER_KEY,
|
|
384
|
+
c.set(USER_KEY, user);
|
|
396
385
|
c.set(AUTH_TRANSPORT_KEY, "bearer");
|
|
397
386
|
const lifecycleReject = await rejectIfTenantTeardown(
|
|
398
387
|
c,
|
|
399
|
-
|
|
388
|
+
user.tenantId,
|
|
400
389
|
resolveTenantLifecycleStatus,
|
|
401
390
|
);
|
|
402
391
|
if (lifecycleReject) return lifecycleReject;
|
|
403
392
|
await next();
|
|
404
|
-
// skip:
|
|
405
|
-
// Response|undefined union honest (same as handleAnonymous).
|
|
393
|
+
// skip: verified-bearer path completed — next() ran; explicit return keeps
|
|
394
|
+
// the Response|undefined union honest (same as handleAnonymous).
|
|
406
395
|
return;
|
|
407
396
|
}
|
|
408
397
|
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -16,17 +16,11 @@ import {
|
|
|
16
16
|
type AuthSessionStatus,
|
|
17
17
|
CSRF_COOKIE_NAME,
|
|
18
18
|
getUser,
|
|
19
|
-
type
|
|
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:
|
|
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
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
|
|
269
|
-
|
|
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, {
|
|
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
|