@cosmicdrift/kumiko-framework 0.165.0 → 0.165.2

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 (157) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -3
  3. package/src/__tests__/consumer-cli.integration.test.ts +32 -0
  4. package/src/__tests__/schema-cli.integration.test.ts +1 -1
  5. package/src/api/__tests__/api.test.ts +267 -19
  6. package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
  7. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
  8. package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
  9. package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
  10. package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +150 -0
  11. package/src/api/__tests__/batch.integration.test.ts +21 -2
  12. package/src/api/__tests__/jwt.test.ts +52 -2
  13. package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
  14. package/src/api/__tests__/sse-broker.test.ts +57 -0
  15. package/src/api/__tests__/sse-route.test.ts +4 -0
  16. package/src/api/auth-routes.ts +178 -33
  17. package/src/api/index.ts +1 -0
  18. package/src/api/jwt.ts +22 -1
  19. package/src/api/routes.ts +103 -35
  20. package/src/api/server.ts +26 -2
  21. package/src/api/sse-broker.ts +38 -0
  22. package/src/bun-db/index.ts +1 -0
  23. package/src/bun-db/query.ts +14 -5
  24. package/src/consumer-cli.ts +60 -13
  25. package/src/db/__tests__/decimal-field.test.ts +3 -3
  26. package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
  27. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
  28. package/src/db/__tests__/located-timestamp.test.ts +19 -0
  29. package/src/db/__tests__/migrate-runner.test.ts +75 -0
  30. package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
  31. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
  32. package/src/db/__tests__/tenant-db-where-merge.test.ts +10 -6
  33. package/src/db/api.ts +2 -2
  34. package/src/db/bun-provider.ts +2 -2
  35. package/src/db/collect-table-metas.ts +3 -3
  36. package/src/db/connection.ts +6 -3
  37. package/src/db/dialect.ts +1 -6
  38. package/src/db/entity-table-meta-types.ts +1 -1
  39. package/src/db/entity-table-meta.ts +49 -32
  40. package/src/db/event-store-executor-context.ts +2 -3
  41. package/src/db/event-store-executor-read.ts +2 -3
  42. package/src/db/event-store-executor-write.ts +8 -0
  43. package/src/db/index.ts +13 -2
  44. package/src/db/located-timestamp.ts +4 -0
  45. package/src/db/migrate-runner.ts +102 -11
  46. package/src/db/pg-error.ts +8 -0
  47. package/src/db/postgres-provider.ts +2 -2
  48. package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
  49. package/src/db/queries/ddl.ts +45 -0
  50. package/src/db/queries/event-store.ts +97 -5
  51. package/src/db/queries/test-stack.ts +4 -30
  52. package/src/db/reference-data.ts +2 -3
  53. package/src/db/replay-migration-sql.ts +114 -12
  54. package/src/db/table-builder.ts +2 -2
  55. package/src/db/tenant-db.ts +3 -5
  56. package/src/engine/__tests__/engine.test.ts +30 -0
  57. package/src/engine/__tests__/schema-builder.test.ts +18 -0
  58. package/src/engine/__tests__/store-table.test.ts +10 -13
  59. package/src/engine/boot-validator/nav.ts +5 -0
  60. package/src/engine/constants.ts +28 -6
  61. package/src/engine/create-app.ts +11 -0
  62. package/src/engine/effective-features.ts +12 -2
  63. package/src/engine/extensions/user-data.ts +12 -4
  64. package/src/engine/feature-ast/extractors/round5.ts +1 -1
  65. package/src/engine/feature-ui-extensions.ts +3 -3
  66. package/src/engine/hook-helpers.ts +3 -1
  67. package/src/engine/index.ts +1 -1
  68. package/src/engine/ownership.ts +4 -3
  69. package/src/engine/registry-ingest.ts +14 -14
  70. package/src/engine/registry-state.ts +6 -3
  71. package/src/engine/schema-builder.ts +1 -0
  72. package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
  73. package/src/engine/steps/_duration-utils.ts +2 -0
  74. package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
  75. package/src/engine/types/config.ts +1 -1
  76. package/src/engine/types/define-handler.ts +1 -1
  77. package/src/engine/types/entity-handlers.ts +1 -1
  78. package/src/engine/types/event-type-map.ts +1 -1
  79. package/src/engine/types/feature.ts +1 -1
  80. package/src/engine/types/fields.ts +1 -1
  81. package/src/engine/types/handlers.ts +1 -1
  82. package/src/engine/types/hooks.ts +1 -1
  83. package/src/engine/types/http-route.ts +1 -1
  84. package/src/engine/types/nav.ts +1 -1
  85. package/src/engine/types/ownership.ts +1 -1
  86. package/src/engine/types/projection.ts +1 -1
  87. package/src/engine/types/relations.ts +1 -1
  88. package/src/engine/types/screen.ts +1 -1
  89. package/src/engine/types/step.ts +1 -1
  90. package/src/engine/types/target-ref.ts +1 -1
  91. package/src/engine/types/tree-node.ts +1 -1
  92. package/src/engine/types/workspace.ts +1 -1
  93. package/src/engine/validate-projection-allowlist.ts +6 -6
  94. package/src/errors/classes.ts +21 -0
  95. package/src/errors/index.ts +1 -0
  96. package/src/errors/write-error-info.ts +6 -2
  97. package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
  98. package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
  99. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
  100. package/src/event-store/admin-api.ts +11 -4
  101. package/src/event-store/event-store.ts +19 -4
  102. package/src/event-store/types.ts +1 -1
  103. package/src/files/__tests__/build-storage-key.test.ts +28 -0
  104. package/src/files/__tests__/local-provider.test.ts +31 -0
  105. package/src/files/__tests__/write-stream.test.ts +3 -3
  106. package/src/files/index.ts +1 -1
  107. package/src/files/local-provider.ts +6 -1
  108. package/src/files/types.ts +8 -1
  109. package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
  110. package/src/jobs/job-runner.ts +41 -11
  111. package/src/logging/types.ts +1 -1
  112. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
  113. package/src/migrations/projection-table-index.ts +1 -1
  114. package/src/observability/index.ts +1 -0
  115. package/src/observability/standard-metrics.ts +35 -2
  116. package/src/observability/types/index.ts +1 -1
  117. package/src/observability/types/metric.ts +1 -1
  118. package/src/observability/types/provider.ts +1 -1
  119. package/src/observability/types/span.ts +1 -1
  120. package/src/pipeline/__tests__/dispatcher.test.ts +212 -0
  121. package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
  122. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
  123. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
  124. package/src/pipeline/dispatch-shared.ts +59 -6
  125. package/src/pipeline/dispatch-stream.ts +50 -11
  126. package/src/pipeline/dispatcher.ts +7 -1
  127. package/src/pipeline/event-consumer-state.ts +16 -13
  128. package/src/pipeline/event-dispatcher-delivery.ts +20 -6
  129. package/src/pipeline/event-dispatcher.ts +22 -0
  130. package/src/pipeline/index.ts +2 -0
  131. package/src/pipeline/system-hooks.ts +87 -0
  132. package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
  133. package/src/rate-limit/resolver.ts +6 -2
  134. package/src/schema-cli.ts +24 -12
  135. package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
  136. package/src/search/reindex-entity.ts +31 -2
  137. package/src/search/types.ts +1 -1
  138. package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
  139. package/src/stack/db.ts +2 -1
  140. package/src/stack/push-entity-projection-tables.ts +2 -1
  141. package/src/stack/request-helper.ts +20 -1
  142. package/src/stack/table-helpers.ts +6 -4
  143. package/src/stack/test-stack.ts +18 -15
  144. package/src/testing/__tests__/late-bound.test.ts +7 -0
  145. package/src/testing/__tests__/wait-for.test.ts +6 -0
  146. package/src/testing/file-provider-contract.ts +26 -6
  147. package/src/testing/index.ts +1 -0
  148. package/src/testing/late-bound.ts +5 -3
  149. package/src/testing/wait-for.ts +3 -0
  150. package/src/testing/without-ambient-temporal.ts +14 -0
  151. package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
  152. package/src/time/geo-tz.ts +1 -1
  153. package/src/time/polyfill.ts +28 -39
  154. package/src/time/tz-context.ts +30 -24
  155. package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
  156. package/src/utils/safe-json.ts +3 -2
  157. package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { Context } from "hono";
2
3
  import { Hono } from "hono";
3
4
  import { deleteCookie, setCookie } from "hono/cookie";
@@ -217,6 +218,12 @@ export type SessionCreator = (user: SessionUser, meta: SessionMetadata) => Promi
217
218
  // there's nothing to revoke.
218
219
  export type SessionRevoker = (sid: string) => Promise<void>;
219
220
 
221
+ // Mass-revoke every live session for a user. Used by password-change and
222
+ // "sign out everywhere" (sessions feature) and by auth-foundation's
223
+ // SessionStore extension point — canonical home so both can re-export
224
+ // the same type instead of restating it.
225
+ export type SessionMassRevoker = (userId: string) => Promise<number>;
226
+
220
227
  // Status reported by the session-store to the auth-middleware. The concrete
221
228
  // type lives on auth-middleware to keep the tight coupling visible there;
222
229
  // auth-routes just re-uses the alias for the AuthRoutesConfig surface.
@@ -274,15 +281,21 @@ export type AuthRoutesConfig = {
274
281
  // /auth/mfa/preauth-enable-start dispatches { preauthSetupToken,
275
282
  // accountLabel } to this handler with a guest identity — no session is
276
283
  // minted, the response is just { setupToken, otpauthUri, recoveryCodes }
277
- // (same shape as auth-mfa's own enable-start.write.ts). No dedicated
278
- // rate-limiter here (unlike mfaVerifyRateLimit): the route already
279
- // inherits the generic L2 authEndpointRateLimit on /api/auth/*, and this
280
- // handler checks no TOTP code, so there's no per-account guessing
281
- // surface to cap — that cap belongs on the later confirm handler.
284
+ // (same shape as auth-mfa's own enable-start.write.ts).
282
285
  mfaPreauthEnableStartHandler?: string;
283
286
  // Maps mfaPreauthEnableStartHandler error codes to HTTP status codes,
284
287
  // same pattern as mfaVerifyErrorStatusMap.
285
288
  mfaPreauthEnableStartErrorStatusMap?: Readonly<Record<string, number>>;
289
+ // Rate-limit for POST /auth/mfa/preauth-enable-start, keyed by client IP.
290
+ // Defaults to in-memory 10/5min. Pass `null` to disable. This route does
291
+ // NOT inherit the generic L2 authEndpointRateLimit on /api/auth/* by
292
+ // default (that's opt-in per-app via runProdApp's rateLimit.auth), and a
293
+ // preauthSetupToken is valid for the full setup-token TTL and not
294
+ // single-use until preauth-confirm burns it — an unrate-limited replay
295
+ // means every hit re-derives a fresh TOTP secret + 8 argon2id recovery-
296
+ // code hashes (recovery-codes.ts, Promise.all), a memory-hard CPU
297
+ // amplifier reachable from a single stolen/leaked token.
298
+ mfaPreauthEnableStartRateLimit?: LoginRateLimiter | null;
286
299
  // Optional: qualified write handler completing the enrollment started by
287
300
  // mfaPreauthEnableStartHandler — takes the secret-carrying setupToken
288
301
  // from that step plus a TOTP code. When set, POST /auth/mfa/preauth-
@@ -398,6 +411,20 @@ export type AuthRoutesConfig = {
398
411
  // subdomain can then forge authenticated state-changing requests. Prefer
399
412
  // setting `allowedOrigins`.
400
413
  unsafeSkipOriginCheck?: boolean;
414
+ // Number of trusted reverse-proxy hops between the client and this
415
+ // process that APPEND (not overwrite) their peer address to
416
+ // `x-forwarded-for` (e.g. nginx `$proxy_add_x_forwarded_for`). Used to
417
+ // derive the client IP for every auth rate-limiter (login, mfa-verify,
418
+ // preauth-enable-start, preauth-confirm) and for requestMeta's session
419
+ // IP — kumiko-framework#1539. Default 0 = legacy behavior, trust the
420
+ // first XFF entry (or x-real-ip) unconditionally; spoofable by design,
421
+ // kept as the default so unconfigured deployments don't regress into a
422
+ // shared "unknown" bucket (mfa-verify/preauth-confirm key on bare IP, no
423
+ // email composite — collapsing everyone into one bucket is a DoS worse
424
+ // than the spoofing hole). Set this to your real proxy hop count (1 for
425
+ // a single ingress/reverse-proxy, 2 for edge-LB + ingress, etc.) to close
426
+ // the hole; see clientIpOf's doc comment for the extraction algorithm.
427
+ trustedProxyHops?: number;
401
428
  };
402
429
 
403
430
  export type PasswordResetConfig = {
@@ -452,15 +479,86 @@ export type SignupConfig = {
452
479
  confirmHandler: string;
453
480
  };
454
481
 
482
+ // Derives the caller IP from proxy headers, single source for the
483
+ // rate-limiter keys below and requestMeta. kumiko-framework#1523/#1522/#1539:
484
+ // `x-forwarded-for` is attacker-controlled unless we know how many trusted
485
+ // proxy hops sit between the client and this process — `trustedProxyHops`
486
+ // (AuthRoutesConfig) is that count.
487
+ //
488
+ // hops === 0 (default): legacy behavior — trust the first XFF entry (or
489
+ // x-real-ip) at face value. Kept as the default so unconfigured
490
+ // deployments don't regress: collapsing everyone into a single "unknown"
491
+ // bucket would turn mfa-verify/preauth-confirm's pure-IP-keyed limiter
492
+ // into a one-request-locks-out-everyone DoS, which is worse than the
493
+ // spoofing hole this issue is about. Apps behind a proxy MUST set this to
494
+ // close the hole — see AuthRoutesConfig.trustedProxyHops.
495
+ //
496
+ // hops >= 1: each trusted proxy appends (not overwrites) its peer address
497
+ // to XFF (e.g. nginx `$proxy_add_x_forwarded_for`), so the last `hops`
498
+ // entries are proxy-supplied and everything before them — including the
499
+ // real client — sits at `entries[length - hops]`. Anything the client
500
+ // itself prepended lands further left and is ignored. If the header has
501
+ // fewer entries than `hops`, the proxy chain is shorter than configured
502
+ // (misconfiguration or a bypassed hop) — fall back to x-real-ip when present,
503
+ // else "unknown". x-real-ip has no hop-count semantics (so it isn't used for
504
+ // hop math), but a shared "unknown" bucket is worse than trusting the
505
+ // proxy-set header when XFF is absent (common nginx X-Real-IP-only setups).
506
+ //
507
+ // Callers that need a hard boundary regardless of this config
508
+ // (preauth-enable-start) additionally key on something the caller can't
509
+ // freely choose (see preauthTokenKeyOf below).
510
+ let warnedUnknownClientIp = false;
511
+ function clientIpOf(
512
+ c: { req: { header(name: string): string | undefined } },
513
+ trustedProxyHops = 0,
514
+ ): string {
515
+ if (trustedProxyHops <= 0) {
516
+ return (
517
+ c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
518
+ c.req.header("x-real-ip") ??
519
+ "unknown"
520
+ );
521
+ }
522
+ const entries = (c.req.header("x-forwarded-for") ?? "")
523
+ .split(",")
524
+ .map((entry) => entry.trim())
525
+ .filter((entry) => entry.length > 0);
526
+ if (entries.length < trustedProxyHops) {
527
+ // nginx often sets only X-Real-IP (no XFF). Prefer that over a shared
528
+ // "unknown" rate-limit bucket (fw#1555) — hop-count math still doesn't
529
+ // apply to X-Real-IP, but a shared bucket is worse than trusting the
530
+ // proxy-set header when the XFF chain is shorter than configured.
531
+ const realIp = c.req.header("x-real-ip")?.trim();
532
+ if (realIp) return realIp;
533
+ if (!warnedUnknownClientIp) {
534
+ warnedUnknownClientIp = true;
535
+ console.warn(
536
+ "[kumiko] trustedProxyHops>=1 but XFF chain too short and no x-real-ip — " +
537
+ 'all such clients share the "unknown" rate-limit bucket. Check proxy headers.',
538
+ );
539
+ }
540
+ return "unknown";
541
+ }
542
+ return entries[entries.length - trustedProxyHops] ?? "unknown";
543
+ }
544
+
545
+ // Second rate-limit axis for preauth-enable-start: unlike the IP, a
546
+ // preauthSetupToken isn't freely choosable by the attacker (it's minted by
547
+ // login.write.ts), so hashing it into a bucket key still caps the replay
548
+ // even when the IP-based bucket is bypassed via header rotation.
549
+ function preauthTokenKeyOf(token: string): string {
550
+ return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
551
+ }
552
+
455
553
  // Extract `ip` and `user-agent` for the sessionCreator.
456
554
  // Hono's `c.req.header(...)` returns undefined for missing headers; we coerce
457
555
  // them to "unknown" rather than throwing because auth-routes are a public
458
556
  // surface and we don't want header-sniffing bugs to break login.
459
- function requestMeta(c: { req: { header(name: string): string | undefined } }): SessionMetadata {
460
- const ip =
461
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
462
- c.req.header("x-real-ip") ??
463
- "unknown";
557
+ function requestMeta(
558
+ c: { req: { header(name: string): string | undefined } },
559
+ trustedProxyHops = 0,
560
+ ): SessionMetadata {
561
+ const ip = clientIpOf(c, trustedProxyHops);
464
562
  const userAgent = c.req.header("user-agent") ?? "unknown";
465
563
  return { ip, userAgent };
466
564
  }
@@ -534,6 +632,12 @@ export function createInMemoryLoginRateLimiter(
534
632
  // each replica its own bucket. namespace separates the login-key keyspace
535
633
  // from the mfa-verify one (they share the same LoginRateLimiter shape but
536
634
  // key on different values).
635
+ // Fail-closed on Redis outage, unlike the in-memory limiter (which never
636
+ // throws): `check`/`reset` propagate any Redis error, so callers 500
637
+ // instead of falling back to unlimited attempts. Deliberate — Redis is
638
+ // already required infra for a multi-replica deployment, and for a
639
+ // security-relevant limiter "briefly unavailable" should read as "briefly
640
+ // down", not "briefly unlimited".
537
641
  export function createRedisLoginRateLimiter(
538
642
  redis: Redis,
539
643
  maxAttempts = 10,
@@ -545,10 +649,26 @@ export function createRedisLoginRateLimiter(
545
649
  return {
546
650
  async check(key) {
547
651
  const redisKey = `${prefix}${key}`;
548
- const count = await redis.incr(redisKey);
549
- if (count === 1) {
550
- await redis.pexpire(redisKey, windowMs);
551
- }
652
+ // INCR then PEXPIRE was two round-trips: a crash/network blip between
653
+ // them (only possible right after count===1, when the key is fresh)
654
+ // left the key permanently without a TTL — the window never resets,
655
+ // the counter only grows, and the bucket is locked out until an
656
+ // operator manually resets/deletes the key. One atomic eval closes
657
+ // the gap: the TTL is set in the same script invocation that creates
658
+ // the key, so no observer (including a crash) can see count===1
659
+ // without the expiry already applied. The `PTTL < 0` branch also
660
+ // heals keys that already exist without a TTL from before this fix
661
+ // shipped (PTTL returns -1 for "no expiry set, key exists") — without
662
+ // it, those pre-existing keys would grow forever with no way back to
663
+ // a normal window.
664
+ const count = (await redis.eval(
665
+ `local c = redis.call('INCR', KEYS[1])
666
+ if c == 1 or redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
667
+ return c`,
668
+ 1,
669
+ redisKey,
670
+ windowMs,
671
+ )) as number;
552
672
  return count <= maxAttempts;
553
673
  },
554
674
  async reset(key) {
@@ -568,6 +688,21 @@ export function createAuthRoutes(
568
688
  // working. High-security apps can opt into "strict" — see AuthRoutesConfig.
569
689
  const cookieSameSite = config.cookieSameSite ?? "lax";
570
690
  const cookieDomain = config.cookieDomain;
691
+ // Single hop-count-aware IP getter for every rate-limit call site below —
692
+ // see AuthRoutesConfig.trustedProxyHops / clientIpOf's doc comment. Fail
693
+ // loud on a non-finite/negative value rather than letting it silently
694
+ // reach clientIpOf, where NaN/negative behaves like "chain too short"
695
+ // and collapses every request into the shared "unknown" bucket.
696
+ if (
697
+ config.trustedProxyHops !== undefined &&
698
+ (!Number.isInteger(config.trustedProxyHops) || config.trustedProxyHops < 0)
699
+ ) {
700
+ throw new Error(
701
+ `createAuthRoutes: trustedProxyHops must be a non-negative integer, got ${config.trustedProxyHops}.`,
702
+ );
703
+ }
704
+ const trustedProxyHops = config.trustedProxyHops ?? 0;
705
+ const getClientIp = (c: Context): string => clientIpOf(c, trustedProxyHops);
571
706
 
572
707
  // Shared tail of every route that ends a request logged-in: create the
573
708
  // session record (if wired), sign the JWT, set the auth+csrf cookies. Was
@@ -577,7 +712,7 @@ export function createAuthRoutes(
577
712
  async function mintSessionAndRespond(c: Context, session: SessionUser): Promise<string> {
578
713
  let sessionForJwt = session;
579
714
  if (config.sessionCreator) {
580
- const sid = await config.sessionCreator(session, requestMeta(c));
715
+ const sid = await config.sessionCreator(session, requestMeta(c, trustedProxyHops));
581
716
  sessionForJwt = { ...session, sid };
582
717
  }
583
718
  const token = await jwt.sign(sessionForJwt);
@@ -612,13 +747,7 @@ export function createAuthRoutes(
612
747
  }
613
748
  const body = parsed.data;
614
749
 
615
- // Client IP derivation is shared between rate-limit check and reset,
616
- // so compute once. Falls back to "unknown" when no proxy header is
617
- // present — consistent bucket for direct-to-server test setups.
618
- const clientIp =
619
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
620
- c.req.header("x-real-ip") ??
621
- "unknown";
750
+ const clientIp = getClientIp(c);
622
751
  const rateLimitKey = `${clientIp}|${body.email.toLowerCase()}`;
623
752
 
624
753
  if (rateLimiter) {
@@ -719,10 +848,7 @@ export function createAuthRoutes(
719
848
  }
720
849
  const body = parsed.data;
721
850
 
722
- const clientIp =
723
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
724
- c.req.header("x-real-ip") ??
725
- "unknown";
851
+ const clientIp = getClientIp(c);
726
852
 
727
853
  if (rateLimiter) {
728
854
  const allowed = await rateLimiter.check(clientIp);
@@ -771,12 +897,17 @@ export function createAuthRoutes(
771
897
  // No JWT is minted here (unlike /auth/login and /auth/mfa/verify) — the
772
898
  // handler's response carries a secret-bearing setupToken that a later
773
899
  // pre-auth confirm step (the same shape auth-mfa's own enable-confirm
774
- // consumes) verifies. No dedicated rate-limiter: see
775
- // AuthRoutesConfig.mfaPreauthEnableStartHandler's doc comment for why
776
- // the generic L2 /api/auth/* limiter is enough here.
900
+ // consumes) verifies. Rate-limited like mfaVerifyRateLimit/
901
+ // mfaPreauthConfirmRateLimit a preauthSetupToken is valid and
902
+ // not-single-use for its whole TTL, so without this cap a replay is a
903
+ // memory-hard CPU amplifier (see AuthRoutesConfig doc comment).
777
904
  if (config.mfaPreauthEnableStartHandler) {
778
905
  const mfaPreauthEnableStartQn = config.mfaPreauthEnableStartHandler;
779
906
  const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
907
+ const rateLimiter =
908
+ config.mfaPreauthEnableStartRateLimit === null
909
+ ? null
910
+ : (config.mfaPreauthEnableStartRateLimit ?? createInMemoryLoginRateLimiter());
780
911
 
781
912
  api.post(Routes.authMfaPreauthEnableStart, async (c) => {
782
913
  const raw = await c.req.json().catch(() => null);
@@ -786,6 +917,23 @@ export function createAuthRoutes(
786
917
  }
787
918
  const body = parsed.data;
788
919
 
920
+ const clientIp = getClientIp(c);
921
+
922
+ if (rateLimiter) {
923
+ const allowed = await rateLimiter.check(clientIp);
924
+ if (!allowed) {
925
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
926
+ }
927
+ // Second axis, independent of the IP-derived bucket above: the
928
+ // preauthSetupToken is minted by login.write.ts and not freely
929
+ // choosable, so this cap survives an attacker rotating
930
+ // x-forwarded-for to bypass the IP bucket (kumiko-framework#1522).
931
+ const tokenAllowed = await rateLimiter.check(preauthTokenKeyOf(body.preauthSetupToken));
932
+ if (!tokenAllowed) {
933
+ return c.json({ isSuccess: false, error: "rate_limited" }, 429);
934
+ }
935
+ }
936
+
789
937
  const result = await dispatcher.write(mfaPreauthEnableStartQn, body, GUEST_USER);
790
938
 
791
939
  if (!result.isSuccess) {
@@ -839,10 +987,7 @@ export function createAuthRoutes(
839
987
  }
840
988
  const body = parsed.data;
841
989
 
842
- const clientIp =
843
- c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
844
- c.req.header("x-real-ip") ??
845
- "unknown";
990
+ const clientIp = getClientIp(c);
846
991
 
847
992
  if (rateLimiter) {
848
993
  const allowed = await rateLimiter.check(clientIp);
package/src/api/index.ts CHANGED
@@ -17,6 +17,7 @@ export type {
17
17
  LoginRateLimiter,
18
18
  SessionChecker,
19
19
  SessionCreator,
20
+ SessionMassRevoker,
20
21
  SessionMetadata,
21
22
  SessionRevoker,
22
23
  } from "./auth-routes";
package/src/api/jwt.ts CHANGED
@@ -191,7 +191,13 @@ export function loadJwtSecretOrKeyring(
191
191
  const keys: Record<string, string> = {};
192
192
  for (const [name, value] of Object.entries(env)) {
193
193
  const match = name.match(JWT_KEY_VAR_PATTERN);
194
- if (!match || !value) continue;
194
+ if (!match) continue;
195
+ if (!value) {
196
+ throw new Error(
197
+ `[jwt] ${name} is set but empty — an empty versioned secret is almost certainly a ` +
198
+ "deploy mistake, not an intentional skip. Unset the var entirely if it's unused.",
199
+ );
200
+ }
195
201
  assertMinLength(name, value);
196
202
  // biome-ignore lint/style/noNonNullAssertion: regex group 1 always present
197
203
  keys[`v${match[1]!}`] = value;
@@ -225,5 +231,20 @@ export function loadJwtSecretOrKeyring(
225
231
  `Check JWT_SECRET_V${currentRaw} is set.`,
226
232
  );
227
233
  }
234
+ // Carry the pre-rotation plain JWT_SECRET into the ring as a verify-only
235
+ // legacy key: sessions signed before JWT_SECRET_V<n> was first set have no
236
+ // `kid`, so verifyWithKeyring's no-kid fallback tries every key in `keys` —
237
+ // if the plain secret isn't one of them, every in-flight session breaks the
238
+ // moment rotation is adopted, exactly the mass-invalidation this keyring
239
+ // exists to avoid. Never becomes signKid — only JWT_SECRET_V<n> can sign.
240
+ // Retirement: has no automatic expiry — verifies unbounded as long as
241
+ // JWT_SECRET stays set. Operators must explicitly unset JWT_SECRET once
242
+ // max token TTL (`ttlSeconds`) has elapsed since cutover to actually
243
+ // retire a rotated-out secret (see run-prod-app.ts boot warning).
244
+ const legacySecret = env["JWT_SECRET"];
245
+ if (legacySecret) {
246
+ assertMinLength("JWT_SECRET", legacySecret);
247
+ keys["legacy"] = legacySecret;
248
+ }
228
249
  return { keys, signKid };
229
250
  }
package/src/api/routes.ts CHANGED
@@ -19,7 +19,25 @@ import { patAllows } from "./pat-scope";
19
19
  import { requestContext } from "./request-context";
20
20
  import { SSE_HEARTBEAT_INTERVAL_MS } from "./sse-route";
21
21
 
22
- export function createApiRoutes(dispatcher: Dispatcher) {
22
+ // SSE frame event names for POST /api/stream. Parallel definition lives in
23
+ // @cosmicdrift/kumiko-headless (dispatcher-live imports that one) — framework
24
+ // cannot depend on headless for four string literals.
25
+ export const StreamFrame = {
26
+ chunk: "chunk",
27
+ ping: "ping",
28
+ done: "done",
29
+ error: "error",
30
+ } as const;
31
+
32
+ export type ApiRoutesOptions = {
33
+ // Override the SSE heartbeat interval (ms). Default SSE_HEARTBEAT_INTERVAL_MS.
34
+ // Deployment-tunable for proxies with different idle timeouts — also used
35
+ // by tests with a short value to exercise the pre-pull race + ping path.
36
+ readonly sseHeartbeatMs?: number;
37
+ };
38
+
39
+ export function createApiRoutes(dispatcher: Dispatcher, options: ApiRoutesOptions = {}) {
40
+ const heartbeatMs = options.sseHeartbeatMs ?? SSE_HEARTBEAT_INTERVAL_MS;
23
41
  const api = new Hono();
24
42
 
25
43
  api.post(Routes.write, async (c) => {
@@ -124,35 +142,76 @@ export function createApiRoutes(dispatcher: Dispatcher) {
124
142
  });
125
143
 
126
144
  // Dispatcher-driven SSE, full auth/CSRF/rate-limit chain (unlike the
127
- // broker-based /sse route). Frame contract for clients: "chunk" (one per
128
- // yielded value, JSON-encoded), "ping" (heartbeat, empty data), "done"
129
- // (terminal, empty data), "error" (terminal, JSON error envelope — the
130
- // response status stays 200 since SSE headers are already flushed before
131
- // dispatch gates run on the generator's first pull).
145
+ // broker-based /sse route). Frame contract for clients: StreamFrame.chunk
146
+ // (one per yielded value, JSON-encoded), .ping (heartbeat, empty data),
147
+ // .done (terminal, empty data), .error (terminal, JSON error envelope —
148
+ // only reachable once the stream is already open, i.e. failures from the
149
+ // second chunk onward). The generator's first `.next()` — which runs the
150
+ // dispatch gates (feature/rate-limit/access/validation) plus the handler's
151
+ // first yield — is raced against a heartbeat timeout BEFORE streamSSE, so
152
+ // a gate failure that settles in time maps to its real HTTP status via
153
+ // queryErrorResponse instead of a flushed-200 error frame (framework#1517).
132
154
  api.post(Routes.stream, async (c) => {
133
155
  const user = getUser(c);
134
156
  const body = await c.req.json<{ type: string; payload: unknown }>();
135
157
  const requestId = requestContext.get()?.requestId;
136
158
 
159
+ const generator = dispatcher.stream(body.type, body.payload, user);
137
160
  try {
138
161
  assertPatAllowed(user, body.type);
139
162
  } catch (e) {
140
163
  return queryErrorResponse(c, toKumiko(e), body.type);
141
164
  }
142
165
 
166
+ // stream.onAbort() only exists once streamSSE opens the response below.
167
+ // Hook the raw request signal directly so a client disconnect during the
168
+ // pre-pull still reclaims the generator instead of leaking a Redis
169
+ // subscription/DB cursor held open inside its still-unresolved first
170
+ // `.next()` (framework#1528).
171
+ const signal = c.req.raw.signal;
172
+ const onPrePullAbort = () => void generator.return(undefined);
173
+ signal.addEventListener("abort", onPrePullAbort);
174
+
175
+ const firstPull = generator.next();
176
+ let prePullTimer: ReturnType<typeof setTimeout> | undefined;
177
+ const settledInTime = await Promise.race([
178
+ firstPull.then(() => true).catch(() => true),
179
+ new Promise<false>((resolve) => {
180
+ prePullTimer = setTimeout(() => resolve(false), heartbeatMs);
181
+ }),
182
+ ]);
183
+ clearTimeout(prePullTimer);
184
+ signal.removeEventListener("abort", onPrePullAbort);
185
+
186
+ if (settledInTime) {
187
+ try {
188
+ await firstPull;
189
+ } catch (e) {
190
+ return queryErrorResponse(c, toKumiko(e), body.type);
191
+ }
192
+ }
193
+
194
+ if (signal.aborted) {
195
+ // Fire-and-forget: settledInTime === false means firstPull is by
196
+ // definition still pending — V8 queues a .return() request behind an
197
+ // in-flight .next(), so awaiting here would block the response until
198
+ // that pending pull resolves (which may be never for an idle stream).
199
+ void generator.return(undefined).catch(() => {});
200
+ return c.body(null, 499 as ContentfulStatusCode); // @cast-boundary non-standard client-closed-request status, Hono's union doesn't include it
201
+ }
202
+
143
203
  return streamSSE(c, async (stream) => {
144
- const generator = dispatcher.stream(body.type, body.payload, user);
145
204
  stream.onAbort(() => {
146
205
  void generator.return(undefined);
147
206
  });
148
207
 
149
208
  try {
150
- await pumpStream(stream, generator, SSE_HEARTBEAT_INTERVAL_MS);
209
+ await pumpStream(stream, generator, heartbeatMs, firstPull);
151
210
  } catch (e) {
152
211
  const err = toKumiko(e);
153
212
  logServerFault(err, requestId, body.type);
154
213
  const { error } = serializeError(err, requestId);
155
- await stream.writeSSE({ event: "error", data: stringifyJson(error) });
214
+ await stream.writeSSE({ event: StreamFrame.error, data: stringifyJson(error) });
156
215
  }
157
216
  });
158
217
  });
@@ -164,40 +223,49 @@ export type SseWriter = {
164
223
  readonly writeSSE: (message: { readonly event: string; readonly data: string }) => Promise<void>;
165
224
  };
166
225
 
167
- // Pull loop for /api/stream: races each generator.next() against a
168
- // heartbeat timer so a slow/idle handler still keeps the connection alive
169
- // ("ping" frames), forwards yielded chunks as "chunk" frames, and emits
170
- // "done" once the generator completes. Factored out of the route handler
171
- // so the heartbeat/ping and abort/cleanup paths are unit-testable with a
172
- // fast heartbeatMs instead of only reachable through SSE_HEARTBEAT_INTERVAL_MS
173
- // (15s) in a full HTTP round-trip.
226
+ // Races each generator.next() against a heartbeat timer so an idle handler keeps the SSE connection alive.
174
227
  export async function pumpStream(
175
228
  stream: SseWriter,
176
229
  generator: AsyncGenerator<unknown>,
177
230
  heartbeatMs: number,
231
+ // Pending (or already-settled) first `.next()` — see the /api/stream route,
232
+ // which races this against a heartbeat timeout before opening streamSSE.
233
+ firstPull?: Promise<IteratorResult<unknown>>,
178
234
  ): Promise<void> {
179
- let pending = generator.next();
180
- while (true) {
181
- let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
182
- const heartbeat = new Promise<"heartbeat">((resolve) => {
183
- heartbeatTimer = setTimeout(() => resolve("heartbeat"), heartbeatMs);
184
- });
185
- let outcome: Awaited<typeof pending> | "heartbeat";
186
- try {
187
- outcome = await Promise.race([pending, heartbeat]);
188
- } finally {
189
- clearTimeout(heartbeatTimer);
190
- }
235
+ let pending = firstPull ?? generator.next();
236
+ try {
237
+ while (true) {
238
+ let heartbeatTimer: ReturnType<typeof setTimeout> | undefined;
239
+ const heartbeat = new Promise<"heartbeat">((resolve) => {
240
+ heartbeatTimer = setTimeout(() => resolve("heartbeat"), heartbeatMs);
241
+ });
242
+ let outcome: Awaited<typeof pending> | "heartbeat";
243
+ try {
244
+ outcome = await Promise.race([pending, heartbeat]);
245
+ } finally {
246
+ clearTimeout(heartbeatTimer);
247
+ }
191
248
 
192
- if (outcome === "heartbeat") {
193
- await stream.writeSSE({ event: "ping", data: "" });
194
- continue;
249
+ if (outcome === "heartbeat") {
250
+ await stream.writeSSE({ event: StreamFrame.ping, data: "" });
251
+ continue;
252
+ }
253
+ if (outcome.done) break;
254
+ await stream.writeSSE({
255
+ event: StreamFrame.chunk,
256
+ data: stringifyJson(outcome.value ?? null),
257
+ });
258
+ pending = generator.next();
195
259
  }
196
- if (outcome.done) break;
197
- await stream.writeSSE({ event: "chunk", data: stringifyJson(outcome.value) });
198
- pending = generator.next();
260
+ await stream.writeSSE({ event: StreamFrame.done, data: "" });
261
+ } finally {
262
+ // Fire-and-forget: if writeSSE threw mid-loop, `pending` (the last
263
+ // generator.next()) may still be unresolved — V8 queues .return()
264
+ // behind an in-flight .next(), so awaiting here would hang until that
265
+ // pull settles (which may be never for a handler stuck on a dead
266
+ // Redis/DB subscription the disconnect just orphaned).
267
+ void generator.return(undefined).catch(() => {});
199
268
  }
200
- await stream.writeSSE({ event: "done", data: "" });
201
269
  }
202
270
 
203
271
  function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode = 200) {
package/src/api/server.ts CHANGED
@@ -34,6 +34,7 @@ import { createEventDispatcher } from "../pipeline/event-dispatcher";
34
34
  import { createLifecycleHooks, type SystemHooks } from "../pipeline/lifecycle-pipeline";
35
35
  import { createMultiStreamApplyContext } from "../pipeline/multi-stream-apply-context";
36
36
  import {
37
+ createAccessInvalidationEventConsumer,
37
38
  createJobTriggerEventConsumer,
38
39
  createSearchEventConsumer,
39
40
  createSseBroadcastEventConsumer,
@@ -113,7 +114,12 @@ export type ServerOptions = {
113
114
  // search enabled when the respective dependency (sseBroker /
114
115
  // context.searchAdapter) is available; jobTrigger enabled when a
115
116
  // jobRunner is wired via dispatcherOptions.
116
- systemConsumers?: { sse?: boolean; search?: boolean; jobTrigger?: boolean };
117
+ systemConsumers?: {
118
+ sse?: boolean;
119
+ search?: boolean;
120
+ jobTrigger?: boolean;
121
+ accessInvalidation?: boolean;
122
+ };
117
123
  // Raw postgres.js client for LISTEN/NOTIFY wake-up (Sprint E.4). When
118
124
  // present, `.start()` subscribes to EVENTS_PUBSUB_CHANNEL — delivery
119
125
  // latency drops from pollIntervalMs to TCP-round-trip. The poll timer
@@ -153,6 +159,9 @@ export type ServerOptions = {
153
159
  // `undefined` → 1 MB default. `0` disables the limit entirely (tests
154
160
  // or bespoke deployments with a reverse-proxy that caps upstream).
155
161
  maxRequestBytes?: number;
162
+ // SSE heartbeat interval for POST /api/stream (ms). Omit → framework default.
163
+ // Tune down behind proxies with aggressive idle timeouts.
164
+ sseHeartbeatMs?: number;
156
165
  // Process lifecycle. When present:
157
166
  // - GET /health/ready reflects lifecycle.state() (200 ready / 503 else)
158
167
  // - eventDispatcher.stop() is auto-registered as a shutdown hook, so
@@ -379,6 +388,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
379
388
  const dispatcher = createDispatcher(options.registry, contextWithObservability, {
380
389
  ...options.dispatcherOptions,
381
390
  lifecycle,
391
+ sseBroker,
382
392
  });
383
393
 
384
394
  // Async event-dispatcher — the replacement for the old transactional
@@ -414,6 +424,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
414
424
  if (jobTriggerConsumerEnabled && jobRunnerForTriggers) {
415
425
  systemConsumers.push(createJobTriggerEventConsumer(jobRunnerForTriggers, options.registry));
416
426
  }
427
+ // Default ON (#1524 global-by-default). Tests that opt out of SSE via
428
+ // systemConsumers.accessInvalidation=false (test-stack mirrors sse off)
429
+ // skip the row so retention prune suites are not blocked by a lagging
430
+ // cursor=0 consumer they never drain.
431
+ const accessInvalidationEnabled =
432
+ options.eventDispatcher?.systemConsumers?.accessInvalidation ?? true;
433
+ if (accessInvalidationEnabled) {
434
+ systemConsumers.push(createAccessInvalidationEventConsumer(sseBroker));
435
+ }
417
436
 
418
437
  // MultiStreamProjections: one EventConsumer per MSP. Handler routes by
419
438
  // event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
@@ -664,7 +683,12 @@ export function buildServer(options: ServerOptions): KumikoServer {
664
683
  if (options.auth) {
665
684
  app.route("/api", createAuthRoutes(dispatcher, jwt, options.auth));
666
685
  }
667
- app.route("/api", createApiRoutes(dispatcher));
686
+ app.route(
687
+ "/api",
688
+ createApiRoutes(dispatcher, {
689
+ ...(options.sseHeartbeatMs !== undefined ? { sseHeartbeatMs: options.sseHeartbeatMs } : {}),
690
+ }),
691
+ );
668
692
  app.route("/api", createSseRoute(sseBroker));
669
693
 
670
694
  // Mount upload/download routes whenever a file provider is resolvable (a