@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
@@ -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
 
@@ -1,6 +1,7 @@
1
1
  import type { Context } from "hono";
2
2
  import { Hono } from "hono";
3
3
  import { deleteCookie, setCookie } from "hono/cookie";
4
+ import type Redis from "ioredis";
4
5
  import { z } from "zod";
5
6
  import { buildSessionRoles } from "../engine/membership-roles";
6
7
  import { createSystemUser } from "../engine/system-user";
@@ -15,17 +16,11 @@ import {
15
16
  type AuthSessionStatus,
16
17
  CSRF_COOKIE_NAME,
17
18
  getUser,
18
- type PatResolver,
19
+ type TokenVerifier,
19
20
  } from "./auth-middleware";
20
21
  import type { JwtHelper } from "./jwt";
21
22
  import { generateToken } from "./tokens";
22
23
 
23
- // Cookie lifetime must track the JWT's exp claim — both are issued together,
24
- // both reference the same session. jwt.ts's createJwtHelper hardcodes
25
- // setExpirationTime("24h"); if that ever becomes configurable this constant
26
- // follows it.
27
- const JWT_TTL_SECONDS = 24 * 60 * 60;
28
-
29
24
  // Resolves the Secure cookie flag. Locked off in dev/test so Playwright
30
25
  // against http://localhost:… can actually receive the cookie. Production
31
26
  // flips it on — browsers drop Secure cookies on http, so a misconfigured
@@ -47,6 +42,10 @@ function setAuthCookies(
47
42
  csrfToken: string;
48
43
  sameSite: "lax" | "strict";
49
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;
50
49
  },
51
50
  ): void {
52
51
  const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
@@ -54,7 +53,7 @@ function setAuthCookies(
54
53
  secure: cookieSecure(),
55
54
  sameSite,
56
55
  path: "/",
57
- maxAge: JWT_TTL_SECONDS,
56
+ maxAge: opts.ttlSeconds,
58
57
  ...(opts.domain !== undefined && { domain: opts.domain }),
59
58
  } as const;
60
59
 
@@ -261,14 +260,11 @@ export type AuthRoutesConfig = {
261
260
  // at login, check it here on every request. Leaving this empty disables
262
261
  // the revocation path — old JWTs stay valid until they expire naturally.
263
262
  sessionChecker?: SessionChecker;
264
- // When true, a JWT WITHOUT a sid is rejected. Use during deploy-rollouts
265
- // once all fresh JWTs emit a sid and the legacy stateless tokens are
266
- // expected to have expired. Default false keeps old tokens working.
267
- sessionStrictMode?: boolean;
268
- // Resolves bearer Personal Access Tokens (PAT_TOKEN_PREFIX) into a
269
- // SessionUser, consulted BEFORE jwt.verify. Wired by the
270
- // personal-access-tokens feature; unwired = PAT auth disabled.
271
- 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;
272
268
  // Per-token request-rate limiter for PAT-authenticated requests, keyed by
273
269
  // the token id (SessionUser.pat.tokenId). Cookie/JWT requests are unaffected.
274
270
  // Reuses the LoginRateLimiter shape (a generic keyed check/reset limiter).
@@ -287,6 +283,12 @@ export type AuthRoutesConfig = {
287
283
  passwordReset?: PasswordResetConfig;
288
284
  // Email-verification flow. Symmetric to passwordReset.
289
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;
290
292
  // Self-Signup (Magic-Link). Wenn wired, mountet POST
291
293
  // /auth/signup-request + /auth/signup-confirm. Confirm returnt JWT-
292
294
  // Cookie + Session-Body wie login.
@@ -357,6 +359,13 @@ export type EmailVerificationConfig = {
357
359
  confirmHandler: string;
358
360
  };
359
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
+
360
369
  // Tenant-Invite Magic-Link. Drei Accept-Branches für klare Separation:
361
370
  // - acceptHandler: logged-in User akzeptiert via JWT (Branch 1)
362
371
  // - acceptWithLoginHandler: anon User mit existing email (Branch 2)
@@ -461,6 +470,37 @@ export function createInMemoryLoginRateLimiter(
461
470
  };
462
471
  }
463
472
 
473
+ // Redis-backed sibling of createInMemoryLoginRateLimiter — same fixed-window
474
+ // semantics (count <= maxAttempts allows, window anchored on first hit), but
475
+ // shared across replicas via INCR/PEXPIRE instead of an in-process Map.
476
+ // runProdApp defaults to this: an in-memory limiter only rate-limits within
477
+ // a single instance, so a multi-replica prod deployment would silently give
478
+ // each replica its own bucket. namespace separates the login-key keyspace
479
+ // from the mfa-verify one (they share the same LoginRateLimiter shape but
480
+ // key on different values).
481
+ export function createRedisLoginRateLimiter(
482
+ redis: Redis,
483
+ maxAttempts = 10,
484
+ windowMs = 5 * 60_000,
485
+ namespace = "login",
486
+ ): LoginRateLimiter {
487
+ const prefix = `kumiko:auth:ratelimit:${namespace}:`;
488
+
489
+ return {
490
+ async check(key) {
491
+ const redisKey = `${prefix}${key}`;
492
+ const count = await redis.incr(redisKey);
493
+ if (count === 1) {
494
+ await redis.pexpire(redisKey, windowMs);
495
+ }
496
+ return count <= maxAttempts;
497
+ },
498
+ async reset(key) {
499
+ await redis.del(`${prefix}${key}`);
500
+ },
501
+ };
502
+ }
503
+
464
504
  export function createAuthRoutes(
465
505
  dispatcher: Dispatcher,
466
506
  jwt: JwtHelper,
@@ -486,7 +526,13 @@ export function createAuthRoutes(
486
526
  }
487
527
  const token = await jwt.sign(sessionForJwt);
488
528
  const csrfToken = generateToken();
489
- 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
+ });
490
536
  return token;
491
537
  }
492
538
 
@@ -697,6 +743,26 @@ export function createAuthRoutes(
697
743
  });
698
744
  }
699
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
+
700
766
  // Self-Signup (Magic-Link). Request mountet wie reset/verify den
701
767
  // silent-success-Pfad mit Token-Mail. Confirm ist anders: returnt
702
768
  // SessionUser → die Route mintet JWT + setzt Cookies (Auto-Login
package/src/api/index.ts CHANGED
@@ -5,10 +5,10 @@ export type {
5
5
  AuthMiddlewareOptions,
6
6
  AuthSessionChecker,
7
7
  AuthSessionStatus,
8
- PatResolver,
9
8
  TenantExists,
10
9
  TenantLifecycleStatusResolver,
11
10
  TenantResolver,
11
+ TokenVerifier,
12
12
  } from "./auth-middleware";
13
13
  export { authMiddleware, getUser, PAT_TOKEN_PREFIX } from "./auth-middleware";
14
14
  export type {
@@ -19,7 +19,11 @@ export type {
19
19
  SessionMetadata,
20
20
  SessionRevoker,
21
21
  } from "./auth-routes";
22
- export { createAuthRoutes, createInMemoryLoginRateLimiter } from "./auth-routes";
22
+ export {
23
+ createAuthRoutes,
24
+ createInMemoryLoginRateLimiter,
25
+ createRedisLoginRateLimiter,
26
+ } from "./auth-routes";
23
27
  export type { CachedResponseInit, CachePolicy } from "./http-cache";
24
28
  export {
25
29
  cacheControlHeader,
@@ -30,8 +34,8 @@ export {
30
34
  etagMatches,
31
35
  parseIfNoneMatch,
32
36
  } from "./http-cache";
33
- export type { JwtHelper, JwtPayload } from "./jwt";
34
- export { createJwtHelper } from "./jwt";
37
+ export type { JwtHelper, JwtKeyring, JwtPayload } from "./jwt";
38
+ export { createJwtHelper, loadJwtSecretOrKeyring } from "./jwt";
35
39
  export { patAllows, qnMatches } from "./pat-scope";
36
40
  export { type RequestContextData, requestContext } from "./request-context";
37
41
  export { requestIdMiddleware } from "./request-id-middleware";
package/src/api/jwt.ts CHANGED
@@ -23,10 +23,82 @@ export type JwtPayload = {
23
23
  export type JwtHelper = {
24
24
  sign(user: SessionUser): Promise<string>;
25
25
  verify(token: string): Promise<JwtPayload>;
26
+ // The TTL this helper signs tokens with, in seconds — the single source for
27
+ // callers (e.g. the auth-cookie's maxAge) that must stay coupled to the JWT's exp.
28
+ readonly ttlSeconds: number;
26
29
  };
27
30
 
28
- export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
29
- const encodedSecret = new TextEncoder().encode(secret);
31
+ // kid secret. All entries verify; `signKid` picks the sign-key. Rotation:
32
+ // add the new kid, flip signKid, keep the old kid around until in-flight
33
+ // tokens expire.
34
+ export type JwtKeyring = {
35
+ readonly keys: Readonly<Record<string, string>>;
36
+ readonly signKid: string;
37
+ };
38
+
39
+ type NormalizedKeyring = {
40
+ readonly verifyKeys: ReadonlyMap<string, Uint8Array>;
41
+ readonly signKid: string | undefined;
42
+ readonly signKey: Uint8Array;
43
+ };
44
+
45
+ function normalizeKeyring(secretOrKeyring: string | JwtKeyring): NormalizedKeyring {
46
+ if (typeof secretOrKeyring === "string") {
47
+ const key = new TextEncoder().encode(secretOrKeyring);
48
+ return { verifyKeys: new Map(), signKid: undefined, signKey: key };
49
+ }
50
+
51
+ const verifyKeys = new Map<string, Uint8Array>();
52
+ for (const [kid, secret] of Object.entries(secretOrKeyring.keys)) {
53
+ verifyKeys.set(kid, new TextEncoder().encode(secret));
54
+ }
55
+ const signKey = verifyKeys.get(secretOrKeyring.signKid);
56
+ if (!signKey) {
57
+ throw new Error(
58
+ `createJwtHelper: signKid "${secretOrKeyring.signKid}" is not present in the keyring`,
59
+ );
60
+ }
61
+ return { verifyKeys, signKid: secretOrKeyring.signKid, signKey };
62
+ }
63
+
64
+ // Tokens carry `kid` in the protected header when signed from a keyring — pick the
65
+ // matching verify-key directly. Tokens without `kid` (single-secret form, or in-flight
66
+ // tokens signed before a rotation) fall back to trying every verify-key.
67
+ async function verifyWithKeyring(token: string, keyring: NormalizedKeyring, issuer: string) {
68
+ const { kid } = jose.decodeProtectedHeader(token);
69
+ if (typeof kid === "string" && keyring.verifyKeys.size > 0) {
70
+ const key = keyring.verifyKeys.get(kid);
71
+ if (!key) {
72
+ throw new Error(`JWT verification failed: unknown kid "${kid}"`);
73
+ }
74
+ return jose.jwtVerify(token, key, { issuer });
75
+ }
76
+
77
+ // ponytail: tries every key in the ring (O(keys) per legacy-token verify) — fine for a
78
+ // rotation window of a handful of keys, revisit if the keyring ever grows large.
79
+ const candidates =
80
+ keyring.verifyKeys.size > 0 ? [...keyring.verifyKeys.values()] : [keyring.signKey];
81
+ let lastError: unknown;
82
+ for (const key of candidates) {
83
+ try {
84
+ return await jose.jwtVerify(token, key, { issuer });
85
+ } catch (err) {
86
+ lastError = err;
87
+ }
88
+ }
89
+ throw lastError instanceof Error
90
+ ? lastError
91
+ : new Error("JWT verification failed: no matching key");
92
+ }
93
+
94
+ const DEFAULT_JWT_TTL_SECONDS = 24 * 60 * 60;
95
+
96
+ export function createJwtHelper(
97
+ secretOrKeyring: string | JwtKeyring,
98
+ issuer = "kumiko",
99
+ ttlSeconds = DEFAULT_JWT_TTL_SECONDS,
100
+ ): JwtHelper {
101
+ const keyring = normalizeKeyring(secretOrKeyring);
30
102
 
31
103
  return {
32
104
  async sign(user) {
@@ -36,19 +108,27 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
36
108
  };
37
109
  if (user.claims) body.claims = { ...user.claims };
38
110
 
111
+ const header: jose.JWTHeaderParameters = keyring.signKid
112
+ ? { alg: "HS256", kid: keyring.signKid }
113
+ : { alg: "HS256" };
114
+
115
+ // iat/exp share one `now` — jose's setIssuedAt()/setExpirationTime(Date)
116
+ // each read the clock separately, letting `exp - iat` drift by a
117
+ // second and making TTL-precision tests flaky.
118
+ const nowSec = Math.floor(Date.now() / 1000);
39
119
  const builder = new jose.SignJWT(body)
40
- .setProtectedHeader({ alg: "HS256" })
120
+ .setProtectedHeader(header)
41
121
  .setSubject(String(user.id))
42
122
  .setIssuer(issuer)
43
- .setIssuedAt()
44
- .setExpirationTime("24h");
123
+ .setIssuedAt(nowSec)
124
+ .setExpirationTime(nowSec + ttlSeconds);
45
125
  if (user.sid) builder.setJti(user.sid);
46
126
 
47
- return builder.sign(encodedSecret);
127
+ return builder.sign(keyring.signKey);
48
128
  },
49
129
 
50
130
  async verify(token) {
51
- const { payload } = await jose.jwtVerify(token, encodedSecret, { issuer });
131
+ const { payload } = await verifyWithKeyring(token, keyring, issuer);
52
132
 
53
133
  // defence-in-depth: valid sig ≠ well-formed claims; malformed payload → throw → 401
54
134
  const tenantId = parseTenantId(payload["tenantId"]);
@@ -84,5 +164,66 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
84
164
  }
85
165
  return result;
86
166
  },
167
+ ttlSeconds,
87
168
  };
88
169
  }
170
+
171
+ const JWT_KEY_VAR_PATTERN = /^JWT_SECRET_V(\d+)$/;
172
+ const JWT_CURRENT_VERSION_VAR = "JWT_SECRET_CURRENT_VERSION";
173
+ // Mirrors authEmailPasswordEnvSchema's JWT_SECRET.min(32) — HS256 minimum.
174
+ // JWT_SECRET_V<n> bypasses that zod schema entirely (it only validates the
175
+ // plain JWT_SECRET name), so this loader is the only gate for the rotation path.
176
+ const MIN_JWT_SECRET_LENGTH = 32;
177
+
178
+ function assertMinLength(name: string, value: string): void {
179
+ if (value.length < MIN_JWT_SECRET_LENGTH) {
180
+ throw new Error(`[jwt] ${name} must be ≥${MIN_JWT_SECRET_LENGTH} chars (HS256 minimum)`);
181
+ }
182
+ }
183
+
184
+ // Env-loader for createJwtHelper's secret-or-keyring param, analog to
185
+ // secrets' loadKeyring: JWT_SECRET_V<n> (+ JWT_SECRET_CURRENT_VERSION picking
186
+ // the active signKid) for rotation, falling back to plain JWT_SECRET when no
187
+ // JWT_SECRET_V<n> is set — so a non-rotating deployment needs no new env vars.
188
+ export function loadJwtSecretOrKeyring(
189
+ env: Readonly<Record<string, string | undefined>>,
190
+ ): string | JwtKeyring {
191
+ const keys: Record<string, string> = {};
192
+ for (const [name, value] of Object.entries(env)) {
193
+ const match = name.match(JWT_KEY_VAR_PATTERN);
194
+ if (!match || !value) continue;
195
+ assertMinLength(name, value);
196
+ // biome-ignore lint/style/noNonNullAssertion: regex group 1 always present
197
+ keys[`v${match[1]!}`] = value;
198
+ }
199
+
200
+ // skip: no JWT_SECRET_V<n> found — single-secret fallback.
201
+ if (Object.keys(keys).length === 0) {
202
+ const secret = env["JWT_SECRET"];
203
+ if (!secret) {
204
+ throw new Error(
205
+ "[jwt] JWT_SECRET not set — set JWT_SECRET for a single key, or " +
206
+ "JWT_SECRET_V1 (+ JWT_SECRET_CURRENT_VERSION=1) for a rotatable keyring.",
207
+ );
208
+ }
209
+ assertMinLength("JWT_SECRET", secret);
210
+ return secret;
211
+ }
212
+
213
+ const currentRaw = env[JWT_CURRENT_VERSION_VAR];
214
+ if (!currentRaw) {
215
+ throw new Error(
216
+ `[jwt] ${JWT_CURRENT_VERSION_VAR} not set — explicit current-version required ` +
217
+ "so adding a new JWT_SECRET_V<n> doesn't auto-promote it to the sign key.",
218
+ );
219
+ }
220
+ const signKid = `v${currentRaw}`;
221
+ if (!keys[signKid]) {
222
+ throw new Error(
223
+ `[jwt] ${JWT_CURRENT_VERSION_VAR}="${currentRaw}" not present in the keyring ` +
224
+ `(have versions: ${Object.keys(keys).sort().join(", ")}). ` +
225
+ `Check JWT_SECRET_V${currentRaw} is set.`,
226
+ );
227
+ }
228
+ return { keys, signKid };
229
+ }
@@ -2,7 +2,10 @@ import type { MiddlewareHandler } from "hono";
2
2
  import { configuredPiiSubjectKms, PII_CIPHERTEXT_PREFIX } from "../crypto";
3
3
 
4
4
  const isProductionEnv = () => process.env["NODE_ENV"] === "production";
5
- const CIPHERTEXT_RE = /kumiko-pii:v1:[^"\s<>\\]*/g;
5
+ // Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
6
+ // older/decrypt-only format version still present in unmigrated rows.
7
+ const CIPHERTEXT_MARKER = "kumiko-pii:v";
8
+ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
6
9
 
7
10
  // A PII subject ciphertext never belongs in an API response — its presence
8
11
  // means a raw DB read (fetchOne/selectMany) leaked to the surface. Dev/test
@@ -19,7 +22,7 @@ export function piiCiphertextResponseGuard(): MiddlewareHandler {
19
22
  if (!contentType.includes("application/json")) return;
20
23
  const text = await c.res.clone().text();
21
24
  // skip: clean response — the common case
22
- if (!text.includes(PII_CIPHERTEXT_PREFIX)) return;
25
+ if (!text.includes(CIPHERTEXT_MARKER)) return;
23
26
 
24
27
  const detail =
25
28
  `[api] JSON response for ${c.req.method} ${c.req.path} contains a PII ciphertext ` +
package/src/api/server.ts CHANGED
@@ -50,7 +50,7 @@ import { PUBLIC_API_PATHS } from "./api-constants";
50
50
  import { type AnonymousAccessConfig, authMiddleware, getUser } from "./auth-middleware";
51
51
  import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
52
52
  import { csrfMiddleware } from "./csrf-middleware";
53
- import { createJwtHelper, type JwtHelper } from "./jwt";
53
+ import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
54
54
  import { observabilityMiddleware } from "./observability-middleware";
55
55
  import { assertOriginGuardConfig, originMiddleware } from "./origin-middleware";
56
56
  import { piiCiphertextResponseGuard } from "./pii-leak-guard";
@@ -69,8 +69,13 @@ import { createSseRoute } from "./sse-route";
69
69
  export type ServerOptions = {
70
70
  registry: Registry;
71
71
  context: AppContext;
72
- jwtSecret: string;
72
+ jwtSecret: string | JwtKeyring;
73
73
  jwtIssuer?: string;
74
+ // JWT lifetime in seconds. Explicit always wins. When omitted, the default
75
+ // depends on `auth.sessionChecker`: wired (revocation possible) keeps the
76
+ // long-lived 24h default; unwired (stateless JWTs, no revocation) drops to
77
+ // 1h so a leaked stateless token has a much smaller exposure window.
78
+ jwtTtl?: number;
74
79
  dispatcherOptions?: Omit<DispatcherOptions, "lifecycle">;
75
80
  systemHooks?: SystemHooks;
76
81
  eventDedup?: EventDedup;
@@ -94,6 +99,8 @@ export type ServerOptions = {
94
99
  pollIntervalMs?: number;
95
100
  batchSize?: number;
96
101
  maxAttempts?: number;
102
+ rearmCooldownMs?: number;
103
+ maxRearmCount?: number;
97
104
  // Opt out of building the dispatcher even if consumers exist — e.g. ops
98
105
  // runs a dedicated dispatcher process, or a test needs to control the
99
106
  // consumer lifecycle manually.
@@ -238,7 +245,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
238
245
  );
239
246
  }
240
247
 
241
- const jwt = createJwtHelper(options.jwtSecret, options.jwtIssuer);
248
+ // Stateless JWTs (no sessionChecker → no revocation) default to a shorter
249
+ // TTL than session-backed ones, since a leaked stateless token can't be
250
+ // revoked and stays valid until it expires. Explicit jwtTtl always wins.
251
+ const defaultJwtTtl = options.auth?.sessionChecker ? 24 * 60 * 60 : 60 * 60;
252
+ const jwt = createJwtHelper(
253
+ options.jwtSecret,
254
+ options.jwtIssuer,
255
+ options.jwtTtl ?? defaultJwtTtl,
256
+ );
242
257
  const sseBroker = options.sseBroker ?? createSseBroker();
243
258
 
244
259
  // Resolve the per-process instance identifier. Prefer explicit
@@ -566,8 +581,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
566
581
  // middleware can reject revoked sids on every request.
567
582
  const jwtGuard = authMiddleware(jwt, {
568
583
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
569
- ...(options.auth?.sessionStrictMode ? { strictMode: options.auth.sessionStrictMode } : {}),
570
- ...(options.auth?.patResolver ? { patResolver: options.auth.patResolver } : {}),
584
+ ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
571
585
  ...(options.auth?.resolveTenantLifecycleStatus
572
586
  ? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
573
587
  : {}),