@spfn/core 0.3.0-beta.32 → 0.3.0-beta.33

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.
@@ -487,22 +487,33 @@ function createBoundedWriter(stream, maxQueue, onClose) {
487
487
  var registryLogger = logger.child("@spfn/core:sse");
488
488
  var SUBSCRIPTION_GRANTED = "subscription.granted";
489
489
  var SUBSCRIPTION_REVOKED = "subscription.revoked";
490
+ var KEY_REVOKED = "key revoked";
490
491
  function createConnectionRegistry() {
491
492
  const bySubject = /* @__PURE__ */ new Map();
493
+ const byKey = /* @__PURE__ */ new Map();
492
494
  const pendingBySubject = /* @__PURE__ */ new Map();
493
- const beginAdmission = (subject, signal) => {
495
+ const pendingByKey = /* @__PURE__ */ new Map();
496
+ const beginAdmission = (subject, keyId, signal) => {
494
497
  const changes = [];
495
498
  addToSet(pendingBySubject, subject, changes);
496
- const end = endOnce(signal, () => removeFromSet(pendingBySubject, subject, changes));
499
+ addToOptionalSet(pendingByKey, keyId, changes);
500
+ const end = endOnce(signal, () => {
501
+ removeFromSet(pendingBySubject, subject, changes);
502
+ removeFromOptionalSet(pendingByKey, keyId, changes);
503
+ });
497
504
  return {
498
505
  cancel: end,
499
506
  open: (connection) => {
500
507
  if (!end()) {
501
- return false;
508
+ return "aborted during admission";
509
+ }
510
+ if (changes.some((change2) => change2.kind === "close")) {
511
+ return KEY_REVOKED;
502
512
  }
503
513
  applyRecorded(connection, changes);
504
514
  addToSet(bySubject, subject, connection);
505
- return true;
515
+ addToOptionalSet(byKey, connection.keyId, connection);
516
+ return null;
506
517
  }
507
518
  };
508
519
  };
@@ -522,15 +533,25 @@ function createConnectionRegistry() {
522
533
  });
523
534
  }
524
535
  };
536
+ const closeByKey = (keyId) => {
537
+ const connections = [...byKey.get(keyId) ?? []];
538
+ pendingByKey.get(keyId)?.forEach((changes) => changes.push({ kind: "close" }));
539
+ connections.forEach((connection) => connection.close(KEY_REVOKED));
540
+ return connections.length;
541
+ };
525
542
  return {
526
543
  beginAdmission,
544
+ closeByKey,
527
545
  remove: (connection) => {
528
546
  if (connection.subject) {
529
547
  removeFromSet(bySubject, connection.subject, connection);
530
548
  }
549
+ removeFromOptionalSet(byKey, connection.keyId, connection);
531
550
  },
532
551
  /** Open connections only — a pending admission is not counted. */
533
552
  count: (subject) => bySubject.get(subject)?.size ?? 0,
553
+ /** Open connections whose token was minted with `keyId`. */
554
+ countByKey: (keyId) => byKey.get(keyId)?.size ?? 0,
534
555
  pendingCount: (subject) => pendingBySubject.get(subject)?.size ?? 0,
535
556
  grant: (subject, eventName, match) => change("grant", subject, eventName, match),
536
557
  revoke: (subject, eventName, match) => change("revoke", subject, eventName, match)
@@ -558,6 +579,16 @@ function addToSet(map, key, value) {
558
579
  set.add(value);
559
580
  map.set(key, set);
560
581
  }
582
+ function addToOptionalSet(map, key, value) {
583
+ if (key) {
584
+ addToSet(map, key, value);
585
+ }
586
+ }
587
+ function removeFromOptionalSet(map, key, value) {
588
+ if (key) {
589
+ removeFromSet(map, key, value);
590
+ }
591
+ }
561
592
  function removeFromSet(map, key, value) {
562
593
  const set = map.get(key);
563
594
  set?.delete(value);
@@ -569,10 +600,11 @@ function matchesRequest(match, request) {
569
600
  return match === void 0 || Object.entries(match.query).every(([field, value]) => Object.hasOwn(request.query, field) && request.query[field] === value);
570
601
  }
571
602
  function applyRecorded(connection, changes) {
572
- changes.forEach(({ kind, eventName, match }) => {
573
- if (!matchesRequest(match, connection.request)) {
603
+ changes.forEach((change) => {
604
+ if (change.kind === "close" || !matchesRequest(change.match, connection.request)) {
574
605
  return;
575
606
  }
607
+ const { kind, eventName } = change;
576
608
  const [from, to] = kind === "grant" ? [connection.denied, connection.allowed] : [connection.allowed, connection.denied];
577
609
  if (from.delete(eventName)) {
578
610
  to.add(eventName);
@@ -602,6 +634,12 @@ function unsubscribeAll(connection) {
602
634
  connection.unsubscribes.forEach((unsubscribe) => unsubscribe());
603
635
  connection.unsubscribes.clear();
604
636
  }
637
+ logger.child("@spfn/core:sse");
638
+ var REGISTRIES_KEY = Symbol.for("@spfn/core:sse-key-close-registries");
639
+ var registries = globalThis[REGISTRIES_KEY] ??= /* @__PURE__ */ new Set();
640
+ function registerKeyCloseRegistry(registry) {
641
+ registries.add(registry);
642
+ }
605
643
 
606
644
  // src/event/sse/handler.ts
607
645
  var sseLogger = logger.child("@spfn/core:sse");
@@ -634,6 +672,7 @@ function createSSEHandler(router, config = {}, tokenManager) {
634
672
  } = config;
635
673
  const registry = createConnectionRegistry();
636
674
  registerSubscriptionControls(router, registry);
675
+ registerKeyCloseRegistry(registry);
637
676
  const streamConfig = { router, authConfig, registry, pingInterval, maxQueue };
638
677
  const handler = async (c) => {
639
678
  const admission = await admitConnection(c, streamConfig, tokenManager);
@@ -655,7 +694,9 @@ function createSSEHandler(router, config = {}, tokenManager) {
655
694
  /** @internal Open registered connections of a subject (tests). */
656
695
  _openConnections: (subject) => registry.count(subject),
657
696
  /** @internal Connects of a subject still in admission (tests). */
658
- _pendingAdmissions: (subject) => registry.pendingCount(subject)
697
+ _pendingAdmissions: (subject) => registry.pendingCount(subject),
698
+ /** @internal Open registered connections whose token was minted with a key (tests). */
699
+ _openConnectionsByKey: (keyId) => registry.countByKey(keyId)
659
700
  });
660
701
  }
661
702
  function registerSubscriptionControls(router, registry) {
@@ -667,17 +708,37 @@ function registerSubscriptionControls(router, registry) {
667
708
  }
668
709
  }
669
710
  async function admitConnection(c, config, tokenManager) {
670
- const { router, authConfig, registry } = config;
671
- const subject = await authenticateToken(c, tokenManager);
672
- if (subject === false) {
711
+ const token = await takeToken(c, tokenManager);
712
+ if (token === false) {
673
713
  return c.json({ error: "Missing token parameter" }, 401);
674
714
  }
675
- if (subject === null) {
676
- return c.json({ error: "Invalid or expired token" }, 401);
715
+ if (token === null) {
716
+ return invalidToken(c);
677
717
  }
718
+ return whilePending(
719
+ token?.subject ? config.registry.beginAdmission(token.subject, token.keyId, c.req.raw.signal) : void 0,
720
+ () => checkConnection(c, config, token, tokenManager)
721
+ );
722
+ }
723
+ function invalidToken(c) {
724
+ return c.json({ error: "Invalid or expired token" }, 401);
725
+ }
726
+ async function checkConnection(c, config, token, tokenManager) {
727
+ if (token && tokenManager && !await tokenManager.checkKey(token)) {
728
+ return invalidToken(c);
729
+ }
730
+ const subject = token?.subject;
678
731
  if (subject) {
679
732
  c.set("sseSubject", subject);
680
733
  }
734
+ const requested = validateRequestedEvents(c, config.router);
735
+ if (requested instanceof Response) {
736
+ return requested;
737
+ }
738
+ const authorized = await authorizeConnection(c, config.router, config.authConfig, subject, requested);
739
+ return authorized instanceof Response ? authorized : { ...authorized, keyId: token?.keyId };
740
+ }
741
+ function validateRequestedEvents(c, router) {
681
742
  const requested = parseRequestedEvents(c);
682
743
  if (!requested) {
683
744
  return c.json({ error: "Missing events parameter" }, 400);
@@ -691,10 +752,7 @@ async function admitConnection(c, config, tokenManager) {
691
752
  validEvents: validEventNames
692
753
  }, 400);
693
754
  }
694
- return whilePending(
695
- subject ? registry.beginAdmission(subject, c.req.raw.signal) : void 0,
696
- () => authorizeConnection(c, router, authConfig, subject, requested)
697
- );
755
+ return requested;
698
756
  }
699
757
  async function whilePending(pending, check) {
700
758
  let outcome;
@@ -793,6 +851,7 @@ async function runStream(c, stream, admission, config) {
793
851
  };
794
852
  const connection = {
795
853
  subject,
854
+ keyId: admission.keyId,
796
855
  requested: admission.requested,
797
856
  allowed: new Set(allowed),
798
857
  denied: new Set(denied),
@@ -802,10 +861,12 @@ async function runStream(c, stream, admission, config) {
802
861
  notify: (frame, eventName) => {
803
862
  if (connectionDead) return;
804
863
  hold.send(() => writer.enqueue({ event: frame, data: JSON.stringify({ event: eventName }) }));
805
- }
864
+ },
865
+ close: cleanup
806
866
  };
807
- if (admission.pending?.open(connection) === false) {
808
- cleanup("aborted during admission");
867
+ const refusal = admission.pending?.open(connection);
868
+ if (refusal) {
869
+ cleanup(refusal);
809
870
  return;
810
871
  }
811
872
  void openSubscriptions(connection, writer, hold, {
@@ -955,7 +1016,7 @@ function passesFrameFilters(eventName, eventDef, payload, ctx) {
955
1016
  return false;
956
1017
  }
957
1018
  }
958
- async function authenticateToken(c, tokenManager) {
1019
+ async function takeToken(c, tokenManager) {
959
1020
  if (!tokenManager) {
960
1021
  return void 0;
961
1022
  }
@@ -963,7 +1024,7 @@ async function authenticateToken(c, tokenManager) {
963
1024
  if (!token) {
964
1025
  return false;
965
1026
  }
966
- return await tokenManager.verify(token);
1027
+ return tokenManager.take(token);
967
1028
  }
968
1029
  function parseRequestedEvents(c) {
969
1030
  const eventsParam = c.req.query("events");
@@ -988,6 +1049,7 @@ var DEFAULT_SSE_STREAM_PATH = "/events/stream";
988
1049
  function sseTokenPath(streamPath) {
989
1050
  return streamPath.replace(/\/[^/]+$/, "/token");
990
1051
  }
1052
+ var tokenLogger = logger.child("@spfn/core:sse");
991
1053
  function hashToken(token) {
992
1054
  return createHash("sha256").update(token).digest("hex");
993
1055
  }
@@ -1050,10 +1112,12 @@ var CacheTokenStore = class {
1050
1112
  var SSETokenManager = class {
1051
1113
  store;
1052
1114
  ttl;
1115
+ verifyKey;
1053
1116
  cleanupTimer = null;
1054
1117
  constructor(config) {
1055
1118
  this.ttl = config?.ttl ?? 3e4;
1056
1119
  this.store = config?.store ?? new InMemoryTokenStore();
1120
+ this.verifyKey = config?.verifyKey;
1057
1121
  const cleanupInterval = config?.cleanupInterval ?? 6e4;
1058
1122
  this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
1059
1123
  this.cleanupTimer.unref();
@@ -1068,13 +1132,16 @@ var SSETokenManager = class {
1068
1132
  return this.ttl;
1069
1133
  }
1070
1134
  /**
1071
- * Issue a new one-time-use token for the given subject
1135
+ * Issue a new one-time-use token for the given subject. `keyId` tags it
1136
+ * with the device key it is minted for; an empty one leaves it untagged.
1072
1137
  */
1073
- async issue(subject) {
1138
+ async issue(subject, options) {
1074
1139
  const token = randomBytes(32).toString("hex");
1140
+ const keyId = options?.keyId || void 0;
1075
1141
  await this.store.set(token, {
1076
1142
  subject,
1077
- expiresAt: Date.now() + this.ttl
1143
+ expiresAt: Date.now() + this.ttl,
1144
+ ...keyId ? { keyId } : {}
1078
1145
  });
1079
1146
  return token;
1080
1147
  }
@@ -1083,11 +1150,43 @@ var SSETokenManager = class {
1083
1150
  * @returns subject string if valid, null if invalid/expired/already consumed
1084
1151
  */
1085
1152
  async verify(token) {
1153
+ return (await this.consume(token))?.subject ?? null;
1154
+ }
1155
+ /**
1156
+ * Consume a token and answer what it was minted with: `null` when it is
1157
+ * unknown, expired, already consumed, or its key no longer holds
1158
+ * (`verifyKey`). The token is gone either way. `take` + `checkKey`.
1159
+ */
1160
+ async consume(token) {
1161
+ const data = await this.take(token);
1162
+ return data && await this.checkKey(data) ? data : null;
1163
+ }
1164
+ /**
1165
+ * The store half of `consume`: take the token out of the store and answer
1166
+ * what it was minted with, `null` when it is unknown, expired or already
1167
+ * consumed — its key is not checked yet (`checkKey`). For a caller that
1168
+ * has to register the connection before the key check, so a revocation
1169
+ * landing during the check reaches it.
1170
+ */
1171
+ async take(token) {
1086
1172
  const data = await this.store.consume(token);
1087
- if (!data || data.expiresAt <= Date.now()) {
1088
- return null;
1173
+ return data && data.expiresAt > Date.now() ? data : null;
1174
+ }
1175
+ /**
1176
+ * The key half of `consume`: `verifyKey` on a tagged token; an untagged
1177
+ * one, or no verifier, holds without a call. A throw refuses and is warned
1178
+ * without its text — it may carry what the verifier read.
1179
+ */
1180
+ async checkKey(data) {
1181
+ if (!data.keyId || !this.verifyKey) {
1182
+ return true;
1183
+ }
1184
+ try {
1185
+ return await this.verifyKey(data.keyId) === true;
1186
+ } catch {
1187
+ tokenLogger.warn("SSE token key check threw \u2014 token refused");
1188
+ return false;
1089
1189
  }
1090
- return data.subject;
1091
1190
  }
1092
1191
  /**
1093
1192
  * Cleanup timer and resources
@@ -1106,6 +1205,8 @@ var PUBLISH_TIMEOUT_MS = 5e3;
1106
1205
  var SUBSCRIBE_TIMEOUT_MS = 5e3;
1107
1206
  var WARN_THROTTLE_MS = 3e4;
1108
1207
  var PUBLISH_BREAKER_COOLDOWN_MS = 1e4;
1208
+ var KEY_CLOSE_RETRY_MS = PUBLISH_BREAKER_COOLDOWN_MS + 500;
1209
+ var PENDING_KEY_CLOSE_CAP = 1e3;
1109
1210
  function withTimeout(promise, ms, label) {
1110
1211
  return new Promise((resolve2, reject) => {
1111
1212
  const timer = setTimeout(() => {
@@ -1140,8 +1241,76 @@ var state = globalThis[STATE_KEY] ??= {
1140
1241
  pubSubCache: void 0,
1141
1242
  subscriber: void 0,
1142
1243
  channelPrefix: void 0,
1143
- wired: /* @__PURE__ */ new Map()
1244
+ wired: /* @__PURE__ */ new Map(),
1245
+ keyCloseDeferrals: /* @__PURE__ */ new Map()
1144
1246
  };
1247
+ function createKeyCloseDeferral(resend) {
1248
+ const pending = /* @__PURE__ */ new Map();
1249
+ let timer;
1250
+ let flushing = false;
1251
+ let disposed = false;
1252
+ const arm = () => {
1253
+ if (timer || disposed) {
1254
+ return;
1255
+ }
1256
+ timer = setTimeout(() => {
1257
+ timer = void 0;
1258
+ void flush();
1259
+ }, KEY_CLOSE_RETRY_MS);
1260
+ timer.unref?.();
1261
+ };
1262
+ const disarm = () => {
1263
+ clearTimeout(timer);
1264
+ timer = void 0;
1265
+ };
1266
+ const flush = async () => {
1267
+ if (flushing || pending.size === 0) {
1268
+ return;
1269
+ }
1270
+ flushing = true;
1271
+ const snapshot = [...pending.values()];
1272
+ pending.clear();
1273
+ for (const message of snapshot) {
1274
+ await resend(message);
1275
+ }
1276
+ flushing = false;
1277
+ if (pending.size > 0) {
1278
+ arm();
1279
+ } else {
1280
+ disarm();
1281
+ }
1282
+ };
1283
+ return {
1284
+ defer: (message) => {
1285
+ if (disposed) {
1286
+ return;
1287
+ }
1288
+ pending.delete(message.keyId);
1289
+ pending.set(message.keyId, message);
1290
+ dropOldestPastCap(pending);
1291
+ throttledWarn("key-close-deferred", "SSE key close deferred \u2014 re-sent when the pub/sub transport recovers", {
1292
+ channel: CONTROL_CHANNEL,
1293
+ pending: pending.size
1294
+ });
1295
+ arm();
1296
+ },
1297
+ flush,
1298
+ dispose: () => {
1299
+ disposed = true;
1300
+ disarm();
1301
+ pending.clear();
1302
+ },
1303
+ keyIds: () => [...pending.keys()]
1304
+ };
1305
+ }
1306
+ function dropOldestPastCap(pending) {
1307
+ const dropped = pending.size - PENDING_KEY_CLOSE_CAP;
1308
+ if (dropped <= 0) {
1309
+ return;
1310
+ }
1311
+ [...pending.keys()].slice(0, dropped).forEach((keyId) => pending.delete(keyId));
1312
+ throttledWarn("key-close-dropped", "SSE key closes dropped \u2014 too many waiting to be re-sent", { dropped });
1313
+ }
1145
1314
  function resolveChannelPrefix(override) {
1146
1315
  return override ?? process.env.SPFN_SSE_CHANNEL_PREFIX ?? DEFAULT_CHANNEL_PREFIX;
1147
1316
  }
@@ -1181,7 +1350,13 @@ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
1181
1350
  void handler(message);
1182
1351
  }
1183
1352
  };
1184
- return {
1353
+ let keyCloses;
1354
+ const deferIfKeyClose = (name, message) => {
1355
+ if (name === CONTROL_CHANNEL && isKeyCloseMessage(message)) {
1356
+ keyCloses.defer(message);
1357
+ }
1358
+ };
1359
+ const cache2 = {
1185
1360
  publish: async (name, message) => {
1186
1361
  const channel = channelPrefix + name;
1187
1362
  let serialized;
@@ -1205,6 +1380,7 @@ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
1205
1380
  const ready = client.status === void 0 || client.status === "ready";
1206
1381
  const now = Date.now();
1207
1382
  if (!ready || degradedUntil > 0 && now < degradedUntil) {
1383
+ deferIfKeyClose(name, message);
1208
1384
  deliverLocal(channel, JSON.parse(serialized));
1209
1385
  return;
1210
1386
  }
@@ -1214,18 +1390,17 @@ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
1214
1390
  try {
1215
1391
  await withTimeout(client.publish(channel, serialized), PUBLISH_TIMEOUT_MS, "pub/sub publish");
1216
1392
  degradedUntil = 0;
1393
+ void keyCloses.flush();
1217
1394
  if (!subscriberHealthy()) {
1218
1395
  throttledWarn("subscriber-down", "Subscriber connection down \u2014 same-pod local fallback", {
1219
1396
  channel
1220
1397
  });
1221
1398
  deliverLocal(channel, JSON.parse(serialized));
1222
1399
  }
1223
- } catch (err) {
1400
+ } catch {
1224
1401
  degradedUntil = Date.now() + PUBLISH_BREAKER_COOLDOWN_MS;
1225
- throttledWarn("publish-failed", "Pub/sub publish failed \u2014 local delivery only", {
1226
- channel,
1227
- error: err instanceof Error ? err.message : String(err)
1228
- });
1402
+ throttledWarn(`publish-failed:${channel}`, "Pub/sub publish failed \u2014 local delivery only", { channel });
1403
+ deferIfKeyClose(name, message);
1229
1404
  deliverLocal(channel, JSON.parse(serialized));
1230
1405
  }
1231
1406
  },
@@ -1244,6 +1419,14 @@ function createRedisPubSubCache(client, channelPrefix, onSubscriber) {
1244
1419
  handlers.set(channel, handler);
1245
1420
  }
1246
1421
  };
1422
+ keyCloses = createKeyCloseDeferral((message) => cache2.publish(CONTROL_CHANNEL, message));
1423
+ state.keyCloseDeferrals.set(cache2, keyCloses);
1424
+ return cache2;
1425
+ }
1426
+ var CONTROL_CHANNEL = "$control";
1427
+ function isKeyCloseMessage(message) {
1428
+ const candidate = message;
1429
+ return !!candidate && candidate.kind === "close" && typeof candidate.keyId === "string" && candidate.keyId !== "" && typeof candidate.origin === "string";
1247
1430
  }
1248
1431
  async function wireEventRouterCache(router, options = {}) {
1249
1432
  if (options.multiInstance === false) {
@@ -1341,6 +1524,8 @@ async function closeEventTransport() {
1341
1524
  for (const event of state.wired.values()) {
1342
1525
  event._resetCache?.();
1343
1526
  }
1527
+ state.keyCloseDeferrals.forEach((deferral) => deferral.dispose());
1528
+ state.keyCloseDeferrals.clear();
1344
1529
  state.subscriber = void 0;
1345
1530
  state.pubSubCache = void 0;
1346
1531
  state.channelPrefix = void 0;
@@ -2573,12 +2758,13 @@ async function registerSSEEndpoint(app, config) {
2573
2758
  const tokenPath = sseTokenPath(streamPath);
2574
2759
  const mwHandlers = resolveEndpointMiddlewares(config).map((mw) => mw.handler);
2575
2760
  const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
2761
+ const getKeyId = authConfig.getKeyId ?? ((c) => c.get("auth")?.keyId);
2576
2762
  app.on(["POST"], [tokenPath], ...mwHandlers, async (c) => {
2577
2763
  const subject = getSubject(c);
2578
2764
  if (!subject) {
2579
2765
  return c.json({ error: "Unable to identify subject" }, 401);
2580
2766
  }
2581
- const token = await tokenManager.issue(subject);
2767
+ const token = await tokenManager.issue(subject, { keyId: getKeyId(c) ?? void 0 });
2582
2768
  return c.json({ token });
2583
2769
  });
2584
2770
  if (debug) {
@@ -3318,10 +3504,10 @@ function readPort(value) {
3318
3504
  return Number.isInteger(port) && port > 0 && port < 65536 ? port : void 0;
3319
3505
  }
3320
3506
  logger.child("@spfn/core:env-registry");
3321
- function validateAllEnv(registries) {
3507
+ function validateAllEnv(registries2) {
3322
3508
  const errors = [];
3323
3509
  const warnings = [];
3324
- for (const registry of registries) {
3510
+ for (const registry of registries2) {
3325
3511
  const result = registry.validateAll();
3326
3512
  errors.push(...result.errors);
3327
3513
  warnings.push(...result.warnings);