@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.
- package/README.md +1 -1
- package/package.json +5 -3
- package/src/__tests__/consumer-cli.integration.test.ts +32 -0
- package/src/__tests__/schema-cli.integration.test.ts +1 -1
- package/src/api/__tests__/api.test.ts +267 -19
- package/src/api/__tests__/auth-middleware-anonymous-access-boot.test.ts +40 -0
- package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +16 -0
- package/src/api/__tests__/auth-routes-mfa-preauth-confirm.test.ts +2 -1
- package/src/api/__tests__/auth-routes-mfa-preauth-enable-start.test.ts +64 -1
- package/src/api/__tests__/auth-routes-trusted-proxy.test.ts +150 -0
- package/src/api/__tests__/batch.integration.test.ts +21 -2
- package/src/api/__tests__/jwt.test.ts +52 -2
- package/src/api/__tests__/redis-login-rate-limiter.integration.test.ts +72 -0
- package/src/api/__tests__/sse-broker.test.ts +57 -0
- package/src/api/__tests__/sse-route.test.ts +4 -0
- package/src/api/auth-routes.ts +178 -33
- package/src/api/index.ts +1 -0
- package/src/api/jwt.ts +22 -1
- package/src/api/routes.ts +103 -35
- package/src/api/server.ts +26 -2
- package/src/api/sse-broker.ts +38 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +14 -5
- package/src/consumer-cli.ts +60 -13
- package/src/db/__tests__/decimal-field.test.ts +3 -3
- package/src/db/__tests__/entity-table-meta-source.test.ts +43 -8
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +14 -1
- package/src/db/__tests__/located-timestamp.test.ts +19 -0
- package/src/db/__tests__/migrate-runner.test.ts +75 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +6 -6
- package/src/db/__tests__/tenant-db-where-merge.test.ts +10 -6
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- package/src/db/collect-table-metas.ts +3 -3
- package/src/db/connection.ts +6 -3
- package/src/db/dialect.ts +1 -6
- package/src/db/entity-table-meta-types.ts +1 -1
- package/src/db/entity-table-meta.ts +49 -32
- package/src/db/event-store-executor-context.ts +2 -3
- package/src/db/event-store-executor-read.ts +2 -3
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/db/index.ts +13 -2
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +102 -11
- package/src/db/pg-error.ts +8 -0
- package/src/db/postgres-provider.ts +2 -2
- package/src/db/queries/__tests__/event-store-idempotency-index.integration.test.ts +80 -0
- package/src/db/queries/ddl.ts +45 -0
- package/src/db/queries/event-store.ts +97 -5
- package/src/db/queries/test-stack.ts +4 -30
- package/src/db/reference-data.ts +2 -3
- package/src/db/replay-migration-sql.ts +114 -12
- package/src/db/table-builder.ts +2 -2
- package/src/db/tenant-db.ts +3 -5
- package/src/engine/__tests__/engine.test.ts +30 -0
- package/src/engine/__tests__/schema-builder.test.ts +18 -0
- package/src/engine/__tests__/store-table.test.ts +10 -13
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +28 -6
- package/src/engine/create-app.ts +11 -0
- package/src/engine/effective-features.ts +12 -2
- package/src/engine/extensions/user-data.ts +12 -4
- package/src/engine/feature-ast/extractors/round5.ts +1 -1
- package/src/engine/feature-ui-extensions.ts +3 -3
- package/src/engine/hook-helpers.ts +3 -1
- package/src/engine/index.ts +1 -1
- package/src/engine/ownership.ts +4 -3
- package/src/engine/registry-ingest.ts +14 -14
- package/src/engine/registry-state.ts +6 -3
- package/src/engine/schema-builder.ts +1 -0
- package/src/engine/steps/__tests__/duration-utils.test.ts +20 -0
- package/src/engine/steps/_duration-utils.ts +2 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +1 -4
- package/src/engine/types/config.ts +1 -1
- package/src/engine/types/define-handler.ts +1 -1
- package/src/engine/types/entity-handlers.ts +1 -1
- package/src/engine/types/event-type-map.ts +1 -1
- package/src/engine/types/feature.ts +1 -1
- package/src/engine/types/fields.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/hooks.ts +1 -1
- package/src/engine/types/http-route.ts +1 -1
- package/src/engine/types/nav.ts +1 -1
- package/src/engine/types/ownership.ts +1 -1
- package/src/engine/types/projection.ts +1 -1
- package/src/engine/types/relations.ts +1 -1
- package/src/engine/types/screen.ts +1 -1
- package/src/engine/types/step.ts +1 -1
- package/src/engine/types/target-ref.ts +1 -1
- package/src/engine/types/tree-node.ts +1 -1
- package/src/engine/types/workspace.ts +1 -1
- package/src/engine/validate-projection-allowlist.ts +6 -6
- package/src/errors/classes.ts +21 -0
- package/src/errors/index.ts +1 -0
- package/src/errors/write-error-info.ts +6 -2
- package/src/event-store/__tests__/admin-api.integration.test.ts +27 -1
- package/src/event-store/__tests__/event-store.integration.test.ts +32 -0
- package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +22 -4
- package/src/event-store/admin-api.ts +11 -4
- package/src/event-store/event-store.ts +19 -4
- package/src/event-store/types.ts +1 -1
- package/src/files/__tests__/build-storage-key.test.ts +28 -0
- package/src/files/__tests__/local-provider.test.ts +31 -0
- package/src/files/__tests__/write-stream.test.ts +3 -3
- package/src/files/index.ts +1 -1
- package/src/files/local-provider.ts +6 -1
- package/src/files/types.ts +8 -1
- package/src/jobs/__tests__/jobs.integration.test.ts +167 -7
- package/src/jobs/job-runner.ts +41 -11
- package/src/logging/types.ts +1 -1
- package/src/migrations/__tests__/kumiko-drift.integration.test.ts +2 -2
- package/src/migrations/projection-table-index.ts +1 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +35 -2
- package/src/observability/types/index.ts +1 -1
- package/src/observability/types/metric.ts +1 -1
- package/src/observability/types/provider.ts +1 -1
- package/src/observability/types/span.ts +1 -1
- package/src/pipeline/__tests__/dispatcher.test.ts +212 -0
- package/src/pipeline/__tests__/event-consumer-state.integration.test.ts +31 -0
- package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +83 -0
- package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +107 -97
- package/src/pipeline/dispatch-shared.ts +59 -6
- package/src/pipeline/dispatch-stream.ts +50 -11
- package/src/pipeline/dispatcher.ts +7 -1
- package/src/pipeline/event-consumer-state.ts +16 -13
- package/src/pipeline/event-dispatcher-delivery.ts +20 -6
- package/src/pipeline/event-dispatcher.ts +22 -0
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/system-hooks.ts +87 -0
- package/src/rate-limit/__tests__/resolver.integration.test.ts +18 -0
- package/src/rate-limit/resolver.ts +6 -2
- package/src/schema-cli.ts +24 -12
- package/src/search/__tests__/reindex-entity.integration.test.ts +24 -1
- package/src/search/reindex-entity.ts +31 -2
- package/src/search/types.ts +1 -1
- package/src/stack/__tests__/setup-test-stack-jobs.integration.test.ts +6 -2
- package/src/stack/db.ts +2 -1
- package/src/stack/push-entity-projection-tables.ts +2 -1
- package/src/stack/request-helper.ts +20 -1
- package/src/stack/table-helpers.ts +6 -4
- package/src/stack/test-stack.ts +18 -15
- package/src/testing/__tests__/late-bound.test.ts +7 -0
- package/src/testing/__tests__/wait-for.test.ts +6 -0
- package/src/testing/file-provider-contract.ts +26 -6
- package/src/testing/index.ts +1 -0
- package/src/testing/late-bound.ts +5 -3
- package/src/testing/wait-for.ts +3 -0
- package/src/testing/without-ambient-temporal.ts +14 -0
- package/src/time/__tests__/polyfill-reinstall.test.ts +17 -0
- package/src/time/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +28 -39
- package/src/time/tz-context.ts +30 -24
- package/src/utils/__tests__/safe-json-temporal.test.ts +14 -0
- package/src/utils/safe-json.ts +3 -2
- package/src/engine/__tests__/registry-facade-sweep.test.ts +0 -80
package/src/api/auth-routes.ts
CHANGED
|
@@ -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).
|
|
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(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
|
|
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
|
-
|
|
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.
|
|
775
|
-
//
|
|
776
|
-
//
|
|
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
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
|
|
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
|
-
|
|
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:
|
|
128
|
-
// yielded value, JSON-encoded),
|
|
129
|
-
// (terminal, empty data),
|
|
130
|
-
//
|
|
131
|
-
//
|
|
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,
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
pending
|
|
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?: {
|
|
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(
|
|
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
|