@cosmicdrift/kumiko-framework 0.165.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +135 -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 +165 -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 +17 -1
- package/src/api/sse-broker.ts +39 -0
- package/src/bun-db/index.ts +1 -0
- package/src/bun-db/query.ts +12 -3
- package/src/consumer-cli.ts +60 -13
- 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 +61 -0
- package/src/db/__tests__/replay-migration-sql.test.ts +131 -2
- package/src/db/__tests__/tenant-db-where-merge.test.ts +6 -2
- package/src/db/api.ts +2 -2
- package/src/db/bun-provider.ts +2 -2
- 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/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 +8 -1
- package/src/db/located-timestamp.ts +4 -0
- package/src/db/migrate-runner.ts +107 -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/tenant-db.ts +2 -4
- 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 +2 -2
- package/src/engine/boot-validator/nav.ts +5 -0
- package/src/engine/constants.ts +32 -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-ui-extensions.ts +2 -2
- 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 +4 -1
- 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 +5 -5
- 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/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 +151 -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 +32 -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/geo-tz.ts +1 -1
- package/src/time/polyfill.ts +21 -38
- package/src/time/tz-context.ts +37 -31
- package/src/utils/__tests__/safe-json-temporal.test.ts +18 -0
- package/src/utils/safe-json.ts +13 -1
- 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,73 @@ 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 "unknown" rather
|
|
503
|
+
// than trusting a shorter, potentially attacker-controlled chain.
|
|
504
|
+
// x-real-ip is NOT consulted in this branch: unlike XFF it has no
|
|
505
|
+
// standardized hop-count semantics, so there's no safe way to validate it
|
|
506
|
+
// against a configured hop count.
|
|
507
|
+
//
|
|
508
|
+
// Callers that need a hard boundary regardless of this config
|
|
509
|
+
// (preauth-enable-start) additionally key on something the caller can't
|
|
510
|
+
// freely choose (see preauthTokenKeyOf below).
|
|
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
|
+
return "unknown";
|
|
528
|
+
}
|
|
529
|
+
return entries[entries.length - trustedProxyHops] ?? "unknown";
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Second rate-limit axis for preauth-enable-start: unlike the IP, a
|
|
533
|
+
// preauthSetupToken isn't freely choosable by the attacker (it's minted by
|
|
534
|
+
// login.write.ts), so hashing it into a bucket key still caps the replay
|
|
535
|
+
// even when the IP-based bucket is bypassed via header rotation.
|
|
536
|
+
function preauthTokenKeyOf(token: string): string {
|
|
537
|
+
return `preauth-token:${createHash("sha256").update(token).digest("hex")}`;
|
|
538
|
+
}
|
|
539
|
+
|
|
455
540
|
// Extract `ip` and `user-agent` for the sessionCreator.
|
|
456
541
|
// Hono's `c.req.header(...)` returns undefined for missing headers; we coerce
|
|
457
542
|
// them to "unknown" rather than throwing because auth-routes are a public
|
|
458
543
|
// surface and we don't want header-sniffing bugs to break login.
|
|
459
|
-
function requestMeta(
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
544
|
+
function requestMeta(
|
|
545
|
+
c: { req: { header(name: string): string | undefined } },
|
|
546
|
+
trustedProxyHops = 0,
|
|
547
|
+
): SessionMetadata {
|
|
548
|
+
const ip = clientIpOf(c, trustedProxyHops);
|
|
464
549
|
const userAgent = c.req.header("user-agent") ?? "unknown";
|
|
465
550
|
return { ip, userAgent };
|
|
466
551
|
}
|
|
@@ -534,6 +619,12 @@ export function createInMemoryLoginRateLimiter(
|
|
|
534
619
|
// each replica its own bucket. namespace separates the login-key keyspace
|
|
535
620
|
// from the mfa-verify one (they share the same LoginRateLimiter shape but
|
|
536
621
|
// key on different values).
|
|
622
|
+
// Fail-closed on Redis outage, unlike the in-memory limiter (which never
|
|
623
|
+
// throws): `check`/`reset` propagate any Redis error, so callers 500
|
|
624
|
+
// instead of falling back to unlimited attempts. Deliberate — Redis is
|
|
625
|
+
// already required infra for a multi-replica deployment, and for a
|
|
626
|
+
// security-relevant limiter "briefly unavailable" should read as "briefly
|
|
627
|
+
// down", not "briefly unlimited".
|
|
537
628
|
export function createRedisLoginRateLimiter(
|
|
538
629
|
redis: Redis,
|
|
539
630
|
maxAttempts = 10,
|
|
@@ -545,10 +636,26 @@ export function createRedisLoginRateLimiter(
|
|
|
545
636
|
return {
|
|
546
637
|
async check(key) {
|
|
547
638
|
const redisKey = `${prefix}${key}`;
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
639
|
+
// INCR then PEXPIRE was two round-trips: a crash/network blip between
|
|
640
|
+
// them (only possible right after count===1, when the key is fresh)
|
|
641
|
+
// left the key permanently without a TTL — the window never resets,
|
|
642
|
+
// the counter only grows, and the bucket is locked out until an
|
|
643
|
+
// operator manually resets/deletes the key. One atomic eval closes
|
|
644
|
+
// the gap: the TTL is set in the same script invocation that creates
|
|
645
|
+
// the key, so no observer (including a crash) can see count===1
|
|
646
|
+
// without the expiry already applied. The `PTTL < 0` branch also
|
|
647
|
+
// heals keys that already exist without a TTL from before this fix
|
|
648
|
+
// shipped (PTTL returns -1 for "no expiry set, key exists") — without
|
|
649
|
+
// it, those pre-existing keys would grow forever with no way back to
|
|
650
|
+
// a normal window.
|
|
651
|
+
const count = (await redis.eval(
|
|
652
|
+
`local c = redis.call('INCR', KEYS[1])
|
|
653
|
+
if c == 1 or redis.call('PTTL', KEYS[1]) < 0 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
|
|
654
|
+
return c`,
|
|
655
|
+
1,
|
|
656
|
+
redisKey,
|
|
657
|
+
windowMs,
|
|
658
|
+
)) as number;
|
|
552
659
|
return count <= maxAttempts;
|
|
553
660
|
},
|
|
554
661
|
async reset(key) {
|
|
@@ -568,6 +675,21 @@ export function createAuthRoutes(
|
|
|
568
675
|
// working. High-security apps can opt into "strict" — see AuthRoutesConfig.
|
|
569
676
|
const cookieSameSite = config.cookieSameSite ?? "lax";
|
|
570
677
|
const cookieDomain = config.cookieDomain;
|
|
678
|
+
// Single hop-count-aware IP getter for every rate-limit call site below —
|
|
679
|
+
// see AuthRoutesConfig.trustedProxyHops / clientIpOf's doc comment. Fail
|
|
680
|
+
// loud on a non-finite/negative value rather than letting it silently
|
|
681
|
+
// reach clientIpOf, where NaN/negative behaves like "chain too short"
|
|
682
|
+
// and collapses every request into the shared "unknown" bucket.
|
|
683
|
+
if (
|
|
684
|
+
config.trustedProxyHops !== undefined &&
|
|
685
|
+
(!Number.isInteger(config.trustedProxyHops) || config.trustedProxyHops < 0)
|
|
686
|
+
) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
`createAuthRoutes: trustedProxyHops must be a non-negative integer, got ${config.trustedProxyHops}.`,
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
const trustedProxyHops = config.trustedProxyHops ?? 0;
|
|
692
|
+
const getClientIp = (c: Context): string => clientIpOf(c, trustedProxyHops);
|
|
571
693
|
|
|
572
694
|
// Shared tail of every route that ends a request logged-in: create the
|
|
573
695
|
// session record (if wired), sign the JWT, set the auth+csrf cookies. Was
|
|
@@ -577,7 +699,7 @@ export function createAuthRoutes(
|
|
|
577
699
|
async function mintSessionAndRespond(c: Context, session: SessionUser): Promise<string> {
|
|
578
700
|
let sessionForJwt = session;
|
|
579
701
|
if (config.sessionCreator) {
|
|
580
|
-
const sid = await config.sessionCreator(session, requestMeta(c));
|
|
702
|
+
const sid = await config.sessionCreator(session, requestMeta(c, trustedProxyHops));
|
|
581
703
|
sessionForJwt = { ...session, sid };
|
|
582
704
|
}
|
|
583
705
|
const token = await jwt.sign(sessionForJwt);
|
|
@@ -612,13 +734,7 @@ export function createAuthRoutes(
|
|
|
612
734
|
}
|
|
613
735
|
const body = parsed.data;
|
|
614
736
|
|
|
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";
|
|
737
|
+
const clientIp = getClientIp(c);
|
|
622
738
|
const rateLimitKey = `${clientIp}|${body.email.toLowerCase()}`;
|
|
623
739
|
|
|
624
740
|
if (rateLimiter) {
|
|
@@ -719,10 +835,7 @@ export function createAuthRoutes(
|
|
|
719
835
|
}
|
|
720
836
|
const body = parsed.data;
|
|
721
837
|
|
|
722
|
-
const clientIp =
|
|
723
|
-
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
724
|
-
c.req.header("x-real-ip") ??
|
|
725
|
-
"unknown";
|
|
838
|
+
const clientIp = getClientIp(c);
|
|
726
839
|
|
|
727
840
|
if (rateLimiter) {
|
|
728
841
|
const allowed = await rateLimiter.check(clientIp);
|
|
@@ -771,12 +884,17 @@ export function createAuthRoutes(
|
|
|
771
884
|
// No JWT is minted here (unlike /auth/login and /auth/mfa/verify) — the
|
|
772
885
|
// handler's response carries a secret-bearing setupToken that a later
|
|
773
886
|
// pre-auth confirm step (the same shape auth-mfa's own enable-confirm
|
|
774
|
-
// consumes) verifies.
|
|
775
|
-
//
|
|
776
|
-
//
|
|
887
|
+
// consumes) verifies. Rate-limited like mfaVerifyRateLimit/
|
|
888
|
+
// mfaPreauthConfirmRateLimit — a preauthSetupToken is valid and
|
|
889
|
+
// not-single-use for its whole TTL, so without this cap a replay is a
|
|
890
|
+
// memory-hard CPU amplifier (see AuthRoutesConfig doc comment).
|
|
777
891
|
if (config.mfaPreauthEnableStartHandler) {
|
|
778
892
|
const mfaPreauthEnableStartQn = config.mfaPreauthEnableStartHandler;
|
|
779
893
|
const statusMap = config.mfaPreauthEnableStartErrorStatusMap ?? {};
|
|
894
|
+
const rateLimiter =
|
|
895
|
+
config.mfaPreauthEnableStartRateLimit === null
|
|
896
|
+
? null
|
|
897
|
+
: (config.mfaPreauthEnableStartRateLimit ?? createInMemoryLoginRateLimiter());
|
|
780
898
|
|
|
781
899
|
api.post(Routes.authMfaPreauthEnableStart, async (c) => {
|
|
782
900
|
const raw = await c.req.json().catch(() => null);
|
|
@@ -786,6 +904,23 @@ export function createAuthRoutes(
|
|
|
786
904
|
}
|
|
787
905
|
const body = parsed.data;
|
|
788
906
|
|
|
907
|
+
const clientIp = getClientIp(c);
|
|
908
|
+
|
|
909
|
+
if (rateLimiter) {
|
|
910
|
+
const allowed = await rateLimiter.check(clientIp);
|
|
911
|
+
if (!allowed) {
|
|
912
|
+
return c.json({ isSuccess: false, error: "rate_limited" }, 429);
|
|
913
|
+
}
|
|
914
|
+
// Second axis, independent of the IP-derived bucket above: the
|
|
915
|
+
// preauthSetupToken is minted by login.write.ts and not freely
|
|
916
|
+
// choosable, so this cap survives an attacker rotating
|
|
917
|
+
// x-forwarded-for to bypass the IP bucket (kumiko-framework#1522).
|
|
918
|
+
const tokenAllowed = await rateLimiter.check(preauthTokenKeyOf(body.preauthSetupToken));
|
|
919
|
+
if (!tokenAllowed) {
|
|
920
|
+
return c.json({ isSuccess: false, error: "rate_limited" }, 429);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
789
924
|
const result = await dispatcher.write(mfaPreauthEnableStartQn, body, GUEST_USER);
|
|
790
925
|
|
|
791
926
|
if (!result.isSuccess) {
|
|
@@ -839,10 +974,7 @@ export function createAuthRoutes(
|
|
|
839
974
|
}
|
|
840
975
|
const body = parsed.data;
|
|
841
976
|
|
|
842
|
-
const clientIp =
|
|
843
|
-
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ??
|
|
844
|
-
c.req.header("x-real-ip") ??
|
|
845
|
-
"unknown";
|
|
977
|
+
const clientIp = getClientIp(c);
|
|
846
978
|
|
|
847
979
|
if (rateLimiter) {
|
|
848
980
|
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 (framework-owned; dispatcher-live
|
|
23
|
+
// has no dependency on this package and keeps its own copy in sse-stream.ts —
|
|
24
|
+
// a drift between the two fails the real-HTTP frame assertions in api.test.ts).
|
|
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). Production uses
|
|
34
|
+
// SSE_HEARTBEAT_INTERVAL_MS; tests pass a short value so the pre-pull
|
|
35
|
+
// race + ping path can be exercised without a 15s wait.
|
|
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
|
|
@@ -379,6 +385,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
379
385
|
const dispatcher = createDispatcher(options.registry, contextWithObservability, {
|
|
380
386
|
...options.dispatcherOptions,
|
|
381
387
|
lifecycle,
|
|
388
|
+
sseBroker,
|
|
382
389
|
});
|
|
383
390
|
|
|
384
391
|
// Async event-dispatcher — the replacement for the old transactional
|
|
@@ -414,6 +421,15 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
414
421
|
if (jobTriggerConsumerEnabled && jobRunnerForTriggers) {
|
|
415
422
|
systemConsumers.push(createJobTriggerEventConsumer(jobRunnerForTriggers, options.registry));
|
|
416
423
|
}
|
|
424
|
+
// Default ON (#1524 global-by-default). Tests that opt out of SSE via
|
|
425
|
+
// systemConsumers.accessInvalidation=false (test-stack mirrors sse off)
|
|
426
|
+
// skip the row so retention prune suites are not blocked by a lagging
|
|
427
|
+
// cursor=0 consumer they never drain.
|
|
428
|
+
const accessInvalidationEnabled =
|
|
429
|
+
options.eventDispatcher?.systemConsumers?.accessInvalidation ?? true;
|
|
430
|
+
if (accessInvalidationEnabled) {
|
|
431
|
+
systemConsumers.push(createAccessInvalidationEventConsumer(sseBroker));
|
|
432
|
+
}
|
|
417
433
|
|
|
418
434
|
// MultiStreamProjections: one EventConsumer per MSP. Handler routes by
|
|
419
435
|
// event.type into the MSP's apply map. MSPs aggregate cross-aggregate but
|
package/src/api/sse-broker.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { userAccessChannel } from "../engine/constants";
|
|
1
2
|
import { generateId } from "../utils";
|
|
2
3
|
|
|
3
4
|
export type SseClient = {
|
|
@@ -17,10 +18,18 @@ export type SseBroker = {
|
|
|
17
18
|
pushToChannel(channel: string, event: SseEvent): void;
|
|
18
19
|
getClientCount(channel: string): number;
|
|
19
20
|
getTotalClientCount(): number;
|
|
21
|
+
// Internal (non-SSE-client) subscription, e.g. dispatch-stream watching
|
|
22
|
+
// for mid-stream access revocation. Kept separate from addClient/
|
|
23
|
+
// pushToChannel: those count towards getClientCount/getTotalClientCount
|
|
24
|
+
// (real SSE connections) and their send/close shape doesn't fit a plain
|
|
25
|
+
// callback listener. Returns an unsubscribe function.
|
|
26
|
+
subscribeAccessInvalidation(userId: string, onInvalidate: () => void): () => void;
|
|
27
|
+
publishAccessInvalidation(userId: string): void;
|
|
20
28
|
};
|
|
21
29
|
|
|
22
30
|
export function createSseBroker(): SseBroker {
|
|
23
31
|
const channels = new Map<string, Map<string, SseClient>>();
|
|
32
|
+
const accessInvalidationListeners = new Map<string, Map<string, () => void>>();
|
|
24
33
|
|
|
25
34
|
function getOrCreateChannel(channel: string): Map<string, SseClient> {
|
|
26
35
|
let clients = channels.get(channel);
|
|
@@ -67,5 +76,35 @@ export function createSseBroker(): SseBroker {
|
|
|
67
76
|
}
|
|
68
77
|
return total;
|
|
69
78
|
},
|
|
79
|
+
|
|
80
|
+
subscribeAccessInvalidation(userId, onInvalidate) {
|
|
81
|
+
const channel = userAccessChannel(userId);
|
|
82
|
+
const listenerId = generateId();
|
|
83
|
+
let listeners = accessInvalidationListeners.get(channel);
|
|
84
|
+
if (!listeners) {
|
|
85
|
+
listeners = new Map();
|
|
86
|
+
accessInvalidationListeners.set(channel, listeners);
|
|
87
|
+
}
|
|
88
|
+
listeners.set(listenerId, onInvalidate);
|
|
89
|
+
return () => {
|
|
90
|
+
const current = accessInvalidationListeners.get(channel);
|
|
91
|
+
// skip: already unsubscribed (e.g. stream ended after a publish already fired)
|
|
92
|
+
if (!current) return;
|
|
93
|
+
current.delete(listenerId);
|
|
94
|
+
if (current.size === 0) accessInvalidationListeners.delete(channel);
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
publishAccessInvalidation(userId) {
|
|
99
|
+
const channel = userAccessChannel(userId);
|
|
100
|
+
const listeners = accessInvalidationListeners.get(channel);
|
|
101
|
+
// skip: no live stream is watching this user right now
|
|
102
|
+
if (!listeners) return;
|
|
103
|
+
// Snapshot before iterating — a fired listener unsubscribes itself,
|
|
104
|
+
// which would mutate `listeners` mid-iteration otherwise.
|
|
105
|
+
for (const onInvalidate of [...listeners.values()]) {
|
|
106
|
+
onInvalidate();
|
|
107
|
+
}
|
|
108
|
+
},
|
|
70
109
|
};
|
|
71
110
|
}
|
package/src/bun-db/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type {
|
|
|
11
11
|
export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection";
|
|
12
12
|
export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query";
|
|
13
13
|
export {
|
|
14
|
+
asEntityTableMeta,
|
|
14
15
|
asRawClient,
|
|
15
16
|
countWhere,
|
|
16
17
|
type DeleteManyBatchedOptions,
|