@spfn/core 0.2.0-beta.12 → 0.2.0-beta.13

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.
@@ -8,6 +8,7 @@ import { registerRoutes } from '@spfn/core/route';
8
8
  import { ErrorHandler, RequestLogger } from '@spfn/core/middleware';
9
9
  import { streamSSE } from 'hono/streaming';
10
10
  import { logger } from '@spfn/core/logger';
11
+ import { randomBytes } from 'crypto';
11
12
  import { initDatabase, getDatabase, closeDatabase } from '@spfn/core/db';
12
13
  import { initCache, getCache, closeCache } from '@spfn/core/cache';
13
14
  import { serve } from '@hono/node-server';
@@ -333,17 +334,26 @@ function loadEnvFiles() {
333
334
  }
334
335
  }
335
336
  var sseLogger = logger.child("@spfn/core:sse");
336
- function createSSEHandler(router, config2 = {}) {
337
+ function createSSEHandler(router, config2 = {}, tokenManager) {
337
338
  const {
338
- pingInterval = 3e4
339
- // headers: customHeaders = {}, // Reserved for future use
339
+ pingInterval = 3e4,
340
+ auth: authConfig
340
341
  } = config2;
341
342
  return async (c) => {
342
- const eventsParam = c.req.query("events");
343
- if (!eventsParam) {
343
+ const subject = await authenticateToken(c, tokenManager);
344
+ if (subject === false) {
345
+ return c.json({ error: "Missing token parameter" }, 401);
346
+ }
347
+ if (subject === null) {
348
+ return c.json({ error: "Invalid or expired token" }, 401);
349
+ }
350
+ if (subject) {
351
+ c.set("sseSubject", subject);
352
+ }
353
+ const requestedEvents = parseRequestedEvents(c);
354
+ if (!requestedEvents) {
344
355
  return c.json({ error: "Missing events parameter" }, 400);
345
356
  }
346
- const requestedEvents = eventsParam.split(",").map((e) => e.trim());
347
357
  const validEventNames = router.eventNames;
348
358
  const invalidEvents = requestedEvents.filter((e) => !validEventNames.includes(e));
349
359
  if (invalidEvents.length > 0) {
@@ -353,19 +363,29 @@ function createSSEHandler(router, config2 = {}) {
353
363
  validEvents: validEventNames
354
364
  }, 400);
355
365
  }
366
+ const allowedEvents = await authorizeEvents(subject, requestedEvents, authConfig);
367
+ if (allowedEvents === null) {
368
+ return c.json({ error: "Not authorized for any requested events" }, 403);
369
+ }
356
370
  sseLogger.debug("SSE connection requested", {
357
- events: requestedEvents,
371
+ events: allowedEvents,
372
+ subject: subject || void 0,
358
373
  clientIp: c.req.header("x-forwarded-for") || c.req.header("x-real-ip")
359
374
  });
360
375
  return streamSSE(c, async (stream) => {
361
376
  const unsubscribes = [];
362
377
  let messageId = 0;
363
- for (const eventName of requestedEvents) {
378
+ for (const eventName of allowedEvents) {
364
379
  const eventDef = router.events[eventName];
365
380
  if (!eventDef) {
366
381
  continue;
367
382
  }
368
383
  const unsubscribe = eventDef.subscribe((payload) => {
384
+ if (subject && authConfig?.filter?.[eventName]) {
385
+ if (!authConfig.filter[eventName](subject, payload)) {
386
+ return;
387
+ }
388
+ }
369
389
  messageId++;
370
390
  const message = {
371
391
  event: eventName,
@@ -384,13 +404,13 @@ function createSSEHandler(router, config2 = {}) {
384
404
  unsubscribes.push(unsubscribe);
385
405
  }
386
406
  sseLogger.info("SSE connection established", {
387
- events: requestedEvents,
407
+ events: allowedEvents,
388
408
  subscriptionCount: unsubscribes.length
389
409
  });
390
410
  await stream.writeSSE({
391
411
  event: "connected",
392
412
  data: JSON.stringify({
393
- subscribedEvents: requestedEvents,
413
+ subscribedEvents: allowedEvents,
394
414
  timestamp: Date.now()
395
415
  })
396
416
  });
@@ -407,7 +427,7 @@ function createSSEHandler(router, config2 = {}) {
407
427
  clearInterval(pingTimer);
408
428
  unsubscribes.forEach((fn) => fn());
409
429
  sseLogger.info("SSE connection closed", {
410
- events: requestedEvents
430
+ events: allowedEvents
411
431
  });
412
432
  }, async (err) => {
413
433
  sseLogger.error("SSE stream error", {
@@ -416,6 +436,99 @@ function createSSEHandler(router, config2 = {}) {
416
436
  });
417
437
  };
418
438
  }
439
+ async function authenticateToken(c, tokenManager) {
440
+ if (!tokenManager) {
441
+ return void 0;
442
+ }
443
+ const token = c.req.query("token");
444
+ if (!token) {
445
+ return false;
446
+ }
447
+ return await tokenManager.verify(token);
448
+ }
449
+ function parseRequestedEvents(c) {
450
+ const eventsParam = c.req.query("events");
451
+ if (!eventsParam) {
452
+ return null;
453
+ }
454
+ return eventsParam.split(",").map((e) => e.trim());
455
+ }
456
+ async function authorizeEvents(subject, requestedEvents, authConfig) {
457
+ if (!subject || !authConfig?.authorize) {
458
+ return requestedEvents;
459
+ }
460
+ const allowed = await authConfig.authorize(subject, requestedEvents);
461
+ if (allowed.length === 0) {
462
+ return null;
463
+ }
464
+ return allowed;
465
+ }
466
+ var InMemoryTokenStore = class {
467
+ tokens = /* @__PURE__ */ new Map();
468
+ async set(token, data) {
469
+ this.tokens.set(token, data);
470
+ }
471
+ async consume(token) {
472
+ const data = this.tokens.get(token);
473
+ if (!data) {
474
+ return null;
475
+ }
476
+ this.tokens.delete(token);
477
+ return data;
478
+ }
479
+ async cleanup() {
480
+ const now = Date.now();
481
+ for (const [token, data] of this.tokens) {
482
+ if (data.expiresAt <= now) {
483
+ this.tokens.delete(token);
484
+ }
485
+ }
486
+ }
487
+ };
488
+ var SSETokenManager = class {
489
+ store;
490
+ ttl;
491
+ cleanupTimer = null;
492
+ constructor(config2) {
493
+ this.ttl = config2?.ttl ?? 3e4;
494
+ this.store = config2?.store ?? new InMemoryTokenStore();
495
+ const cleanupInterval = config2?.cleanupInterval ?? 6e4;
496
+ this.cleanupTimer = setInterval(() => void this.store.cleanup(), cleanupInterval);
497
+ this.cleanupTimer.unref();
498
+ }
499
+ /**
500
+ * Issue a new one-time-use token for the given subject
501
+ */
502
+ async issue(subject) {
503
+ const token = randomBytes(32).toString("hex");
504
+ await this.store.set(token, {
505
+ token,
506
+ subject,
507
+ expiresAt: Date.now() + this.ttl
508
+ });
509
+ return token;
510
+ }
511
+ /**
512
+ * Verify and consume a token
513
+ * @returns subject string if valid, null if invalid/expired/already consumed
514
+ */
515
+ async verify(token) {
516
+ const data = await this.store.consume(token);
517
+ if (!data || data.expiresAt <= Date.now()) {
518
+ return null;
519
+ }
520
+ return data.subject;
521
+ }
522
+ /**
523
+ * Cleanup timer and resources
524
+ */
525
+ destroy() {
526
+ if (this.cleanupTimer) {
527
+ clearInterval(this.cleanupTimer);
528
+ this.cleanupTimer = null;
529
+ }
530
+ }
531
+ };
419
532
  function createHealthCheckHandler(detailed) {
420
533
  return async (c) => {
421
534
  const response = {
@@ -641,13 +754,36 @@ function registerSSEEndpoint(app, config2) {
641
754
  return;
642
755
  }
643
756
  const eventsConfig = config2.eventsConfig ?? {};
644
- const path = eventsConfig.path ?? "/events/stream";
757
+ const streamPath = eventsConfig.path ?? "/events/stream";
758
+ const authConfig = eventsConfig.auth;
645
759
  const debug = isDebugMode(config2);
646
- app.get(path, createSSEHandler(config2.events, eventsConfig));
760
+ let tokenManager;
761
+ if (authConfig?.enabled) {
762
+ tokenManager = new SSETokenManager({
763
+ ttl: authConfig.tokenTtl,
764
+ store: authConfig.store
765
+ });
766
+ const tokenPath = streamPath.replace(/\/[^/]+$/, "/token");
767
+ const mwHandlers = (config2.middlewares ?? []).map((mw) => mw.handler);
768
+ const getSubject = authConfig.getSubject ?? ((c) => c.get("auth")?.userId ?? null);
769
+ app.post(tokenPath, ...mwHandlers, async (c) => {
770
+ const subject = getSubject(c);
771
+ if (!subject) {
772
+ return c.json({ error: "Unable to identify subject" }, 401);
773
+ }
774
+ const token = await tokenManager.issue(subject);
775
+ return c.json({ token });
776
+ });
777
+ if (debug) {
778
+ serverLogger.info(`\u2713 SSE token endpoint registered at POST ${tokenPath}`);
779
+ }
780
+ }
781
+ app.get(streamPath, createSSEHandler(config2.events, eventsConfig, tokenManager));
647
782
  if (debug) {
648
783
  const eventNames = config2.events.eventNames;
649
- serverLogger.info(`\u2713 SSE endpoint registered at ${path}`, {
650
- events: eventNames
784
+ serverLogger.info(`\u2713 SSE endpoint registered at ${streamPath}`, {
785
+ events: eventNames,
786
+ auth: !!authConfig?.enabled
651
787
  });
652
788
  }
653
789
  }