@zintrust/socket 0.4.58 → 0.4.59

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 CHANGED
@@ -189,6 +189,50 @@ Accepted publish authorization headers:
189
189
 
190
190
  You can also provide `channels` instead of `channel`, and `name` instead of `event`.
191
191
 
192
+ ## Example: Core-Owned Policy Hooks
193
+
194
+ Projects can keep the transport routes owned by core while still supplying business rules through `config/broadcast.ts`.
195
+
196
+ ```ts
197
+ export default {
198
+ default: 'inmemory',
199
+ socket: {
200
+ authMiddleware: ['auth', 'jwt'],
201
+ async authorize(_request, context) {
202
+ if (context.channelName.startsWith('private-')) {
203
+ return {
204
+ authorized: context.user !== null && context.user !== undefined,
205
+ };
206
+ }
207
+
208
+ if (context.channelName.startsWith('public-')) {
209
+ return {
210
+ authorized: true,
211
+ };
212
+ }
213
+
214
+ return {
215
+ authorized: false,
216
+ };
217
+ },
218
+ async publish(_request, context) {
219
+ if (context.event.startsWith('admin.')) {
220
+ return {
221
+ allowed: context.user !== null && context.user !== undefined,
222
+ message: 'Admin publish requires an authenticated user.',
223
+ };
224
+ }
225
+
226
+ return {
227
+ allowed: true,
228
+ };
229
+ },
230
+ },
231
+ };
232
+ ```
233
+
234
+ The publish hook may also rewrite the outgoing event, channels, data, or `socketId` before the framework fans the event out.
235
+
192
236
  ## Example: Auth Request
193
237
 
194
238
  ```http
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zintrust/socket",
3
- "version": "0.4.58",
4
- "buildDate": "2026-04-04T19:59:22.567Z",
3
+ "version": "0.4.59",
4
+ "buildDate": "2026-04-04T22:09:08.837Z",
5
5
  "buildEnvironment": {
6
6
  "node": "v22.22.1",
7
7
  "platform": "darwin",
@@ -21,21 +21,25 @@
21
21
  ]
22
22
  },
23
23
  "files": {
24
+ "build-manifest.json": {
25
+ "size": 947,
26
+ "sha256": "2e619502513f9b465881fdb6cb9cda78bb082e6df9bc20d4f1d62a1f7173174b"
27
+ },
24
28
  "index.d.ts": {
25
29
  "size": 932,
26
30
  "sha256": "6432952783fd7eacfc46813fcbd6e96672ff94c73fb0bad8e2f20fc278c64377"
27
31
  },
28
32
  "index.js": {
29
- "size": 28400,
30
- "sha256": "972ccf9128c9915e3bbcf6f91f3332ab5d31e7aaa8ca828be838d231ef47eeb5"
33
+ "size": 35358,
34
+ "sha256": "6a43f6cdc1591b1dc7304e5ccd66be1e41aaa7419546eb0258a280b8ea9b8642"
31
35
  },
32
36
  "register.d.ts": {
33
37
  "size": 16,
34
38
  "sha256": "71d366165dd36f1675aa253a76262b226fb6c62e5ab632746b8aea61c0c625fc"
35
39
  },
36
40
  "register.js": {
37
- "size": 419,
38
- "sha256": "e5c28fd549e3fd5dbee6a211608acb48fa4a8b4ef11b5823668510cde3d924c2"
41
+ "size": 744,
42
+ "sha256": "a3233333a9dfc2357a198aac093cc4dfa1a3e83bb4285c52fce639f0ab1364ad"
39
43
  }
40
44
  }
41
45
  }
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Cloudflare, isArray, isNonEmptyString, Router, SocketFeature, } from '@zintrust/core';
1
+ import { Cloudflare, broadcastConfig, ErrorFactory, isArray, isNonEmptyString, Logger, middlewareConfig, Router, SocketFeature, } from '@zintrust/core';
2
2
  const encoder = new TextEncoder();
3
3
  const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
4
4
  const socketHubBindingName = 'ZT_SOCKET_HUB';
@@ -502,6 +502,114 @@ const parseJsonResponse = async (response) => {
502
502
  }
503
503
  }
504
504
  };
505
+ const isSocketAuthorizerHandler = (value) => {
506
+ return typeof value === 'function';
507
+ };
508
+ const isSocketAuthorizer = (value) => {
509
+ const candidate = value;
510
+ return typeof value === 'object' && value !== null && typeof candidate?.authorize === 'function';
511
+ };
512
+ const createDefaultSocketAuthorizer = () => {
513
+ return Object.freeze({
514
+ async authorize(_request, context) {
515
+ if (context.channelName.startsWith('private-') ||
516
+ context.channelName.startsWith('presence-')) {
517
+ return {
518
+ authorized: context.user !== null && context.user !== undefined,
519
+ };
520
+ }
521
+ return {
522
+ authorized: false,
523
+ };
524
+ },
525
+ });
526
+ };
527
+ const resolveSocketAuthorizer = () => {
528
+ const configured = broadcastConfig.socket.authorize;
529
+ if (configured === undefined) {
530
+ return createDefaultSocketAuthorizer();
531
+ }
532
+ if (isSocketAuthorizerHandler(configured)) {
533
+ return Object.freeze({
534
+ authorize: configured,
535
+ });
536
+ }
537
+ if (isSocketAuthorizer(configured)) {
538
+ return configured;
539
+ }
540
+ throw ErrorFactory.createConfigError('broadcastConfig.socket.authorize must be a function or an object with an authorize(request, context) method.');
541
+ };
542
+ const isSocketPublishPolicyHandler = (value) => {
543
+ return typeof value === 'function';
544
+ };
545
+ const isSocketPublishPolicy = (value) => {
546
+ const candidate = value;
547
+ return typeof value === 'object' && value !== null && typeof candidate?.authorize === 'function';
548
+ };
549
+ const createDefaultSocketPublishPolicy = () => {
550
+ return Object.freeze({
551
+ async authorize() {
552
+ return {
553
+ allowed: true,
554
+ };
555
+ },
556
+ });
557
+ };
558
+ const resolveSocketPublishPolicy = () => {
559
+ const configured = broadcastConfig.socket.publish;
560
+ if (configured === undefined) {
561
+ return createDefaultSocketPublishPolicy();
562
+ }
563
+ if (isSocketPublishPolicyHandler(configured)) {
564
+ return Object.freeze({
565
+ authorize: configured,
566
+ });
567
+ }
568
+ if (isSocketPublishPolicy(configured)) {
569
+ return configured;
570
+ }
571
+ throw ErrorFactory.createConfigError('broadcastConfig.socket.publish must be a function or an object with an authorize(request, context) method.');
572
+ };
573
+ const resolveSocketAuthMiddleware = () => {
574
+ const configured = broadcastConfig.socket.authMiddleware
575
+ .filter(isNonEmptyString)
576
+ .map((value) => value.trim())
577
+ .filter((value) => value !== '');
578
+ const middleware = configured.length > 0 ? configured : ['auth'];
579
+ const known = new Set(Object.keys(middlewareConfig.route));
580
+ const unknown = middleware.filter((value) => !known.has(value));
581
+ if (unknown.length > 0) {
582
+ throw ErrorFactory.createConfigError(`Unknown socket auth middleware configured: ${unknown.join(', ')}`);
583
+ }
584
+ return middleware;
585
+ };
586
+ const isSocketAuthRouteOverrideEnabled = () => {
587
+ return broadcastConfig.socket.allowAuthRouteOverride === true;
588
+ };
589
+ const routeExists = (router, method, path) => {
590
+ return router.routes.some((route) => route.method === method && route.path === path);
591
+ };
592
+ const assertReservedSocketRouteAvailable = (router, method, path, options) => {
593
+ if (!routeExists(router, method, path)) {
594
+ return;
595
+ }
596
+ if (options?.allowOverride === true && isSocketAuthRouteOverrideEnabled()) {
597
+ return;
598
+ }
599
+ throw ErrorFactory.createConfigError(`Socket compatibility route ${method} ${path} is reserved while sockets are enabled.`);
600
+ };
601
+ const createSocketAuthPayload = async (settings, socketId, channelName, channelData) => {
602
+ if (settings.secret.trim() === '' || settings.appKey.trim() === '') {
603
+ throw ErrorFactory.createConfigError('Socket auth is not configured.');
604
+ }
605
+ const signature = await hmacSha256Hex(settings.secret, channelData === undefined
606
+ ? `${socketId}:${channelName}`
607
+ : `${socketId}:${channelName}:${channelData}`);
608
+ return {
609
+ auth: `${settings.appKey}:${signature}`,
610
+ ...(channelData === undefined ? {} : { channel_data: channelData }),
611
+ };
612
+ };
505
613
  const parsePublishPayload = (payload) => {
506
614
  let event = '';
507
615
  if (isNonEmptyString(payload.name)) {
@@ -527,6 +635,29 @@ const parsePublishPayload = (payload) => {
527
635
  ...(isNonEmptyString(payload.socket_id) ? { socket_id: payload.socket_id.trim() } : {}),
528
636
  };
529
637
  };
638
+ const normalizePublishDecisionPayload = (payload, decision) => {
639
+ const event = isNonEmptyString(decision.event) ? decision.event.trim() : payload.event;
640
+ const channels = isArray(decision.channels)
641
+ ? decision.channels.filter(isNonEmptyString).map((item) => item.trim())
642
+ : payload.channels;
643
+ if (event === '' || channels.length === 0) {
644
+ return null;
645
+ }
646
+ return {
647
+ channels,
648
+ event,
649
+ data: decision.data === undefined ? payload.data : decision.data,
650
+ ...(() => {
651
+ if (isNonEmptyString(decision.socketId)) {
652
+ return { socket_id: decision.socketId.trim() };
653
+ }
654
+ if (payload.socket_id === undefined) {
655
+ return {};
656
+ }
657
+ return { socket_id: payload.socket_id };
658
+ })(),
659
+ };
660
+ };
530
661
  const forwardPublishToHub = async (settings, payload, envSource) => {
531
662
  const stub = getSocketHubStub(settings, envSource);
532
663
  if (stub === null) {
@@ -612,10 +743,6 @@ const respondUpgradeRequired = (req, res) => {
612
743
  };
613
744
  const authenticateSubscription = async (req, res) => {
614
745
  const settings = getSocketRuntimeSettings();
615
- if (settings.secret.trim() === '' || settings.appKey.trim() === '') {
616
- res.setStatus(503).json({ error: 'Socket auth is not configured.' });
617
- return;
618
- }
619
746
  const body = req.getBody();
620
747
  const payload = body !== null && typeof body === 'object' ? body : {};
621
748
  const socketId = isNonEmptyString(payload['socket_id']) ? payload['socket_id'].trim() : '';
@@ -629,13 +756,28 @@ const authenticateSubscription = async (req, res) => {
629
756
  res.setStatus(400).json({ error: 'socket_id and channel_name are required.' });
630
757
  return;
631
758
  }
632
- const signature = await hmacSha256Hex(settings.secret, channelData === undefined
633
- ? `${socketId}:${channelName}`
634
- : `${socketId}:${channelName}:${channelData}`);
635
- res.json({
636
- auth: `${settings.appKey}:${signature}`,
637
- ...(channelData === undefined ? {} : { channel_data: channelData }),
759
+ const authorizer = resolveSocketAuthorizer();
760
+ const decision = await authorizer.authorize(req, {
761
+ channelName,
762
+ socketId,
763
+ user: req.user ?? null,
764
+ channelData,
638
765
  });
766
+ if (decision.authorized !== true) {
767
+ res.setStatus(403).json({ message: 'Forbidden' });
768
+ return;
769
+ }
770
+ const resolvedChannelData = isNonEmptyString(decision.channelData)
771
+ ? decision.channelData.trim()
772
+ : channelData;
773
+ if (isNonEmptyString(decision.auth)) {
774
+ res.json({
775
+ auth: decision.auth.trim(),
776
+ ...(resolvedChannelData === undefined ? {} : { channel_data: resolvedChannelData }),
777
+ });
778
+ return;
779
+ }
780
+ res.json(await createSocketAuthPayload(settings, socketId, channelName, resolvedChannelData));
639
781
  };
640
782
  const publishEvent = async (req, res) => {
641
783
  const settings = getSocketRuntimeSettings();
@@ -658,24 +800,66 @@ const publishEvent = async (req, res) => {
658
800
  res.setStatus(400).json({ error: 'event/name and channel/channels are required.' });
659
801
  return;
660
802
  }
803
+ const publishPolicy = resolveSocketPublishPolicy();
804
+ const decision = await publishPolicy.authorize(req, {
805
+ appId: appId.trim(),
806
+ channels: normalizedPayload.channels,
807
+ event: normalizedPayload.event,
808
+ data: normalizedPayload.data,
809
+ socketId: normalizedPayload.socket_id,
810
+ user: req.user ?? null,
811
+ });
812
+ if (decision.allowed !== true) {
813
+ res.setStatus(decision.statusCode ?? 403).json({
814
+ message: decision.message ?? 'Forbidden',
815
+ });
816
+ return;
817
+ }
818
+ const allowedPayload = normalizePublishDecisionPayload(normalizedPayload, decision);
819
+ if (allowedPayload === null) {
820
+ res
821
+ .setStatus(400)
822
+ .json({ error: 'publish policy must return a non-empty event and channels.' });
823
+ return;
824
+ }
661
825
  if (shouldUseCloudflareHub(settings)) {
662
- const response = await forwardPublishToHub(settings, normalizedPayload, Cloudflare.getWorkersEnv());
826
+ const response = await forwardPublishToHub(settings, allowedPayload, Cloudflare.getWorkersEnv());
663
827
  const responseBody = await parseJsonResponse(response);
664
828
  res.setStatus(response.status).json(responseBody);
665
829
  return;
666
830
  }
667
- const deliveries = publishToChannels(getNodeSocketState(), normalizedPayload.channels, normalizedPayload.event, normalizedPayload.data, normalizedPayload.socket_id);
831
+ const deliveries = publishToChannels(getNodeSocketState(), allowedPayload.channels, allowedPayload.event, allowedPayload.data, allowedPayload.socket_id);
668
832
  res.setStatus(202).json({
669
833
  ok: true,
670
- channels: normalizedPayload.channels,
671
- event: normalizedPayload.event,
834
+ channels: allowedPayload.channels,
835
+ event: allowedPayload.event,
672
836
  deliveries,
673
837
  });
674
838
  };
675
839
  const registerSocketRoutes = (router) => {
676
840
  const settings = getSocketRuntimeSettings();
841
+ const allowAuthRouteOverride = isSocketAuthRouteOverrideEnabled();
842
+ assertReservedSocketRouteAvailable(router, 'GET', `${settings.path}/:appKey`);
843
+ assertReservedSocketRouteAvailable(router, 'POST', '/broadcasting/auth', {
844
+ allowOverride: true,
845
+ });
846
+ assertReservedSocketRouteAvailable(router, 'POST', '/apps/:appId/events');
677
847
  Router.get(router, `${settings.path}/:appKey`, respondUpgradeRequired);
678
- Router.post(router, '/broadcasting/auth', authenticateSubscription);
848
+ if (allowAuthRouteOverride) {
849
+ if (!routeExists(router, 'POST', '/broadcasting/auth')) {
850
+ Logger.warn('SOCKET_ALLOW_AUTH_ROUTE_OVERRIDE=true but POST /broadcasting/auth is not registered by the application.');
851
+ }
852
+ }
853
+ else {
854
+ Router.post(router, '/broadcasting/auth', authenticateSubscription, {
855
+ middleware: resolveSocketAuthMiddleware(),
856
+ meta: {
857
+ summary: 'Socket broadcast authorization',
858
+ tags: ['Sockets'],
859
+ responseStatus: 200,
860
+ },
861
+ });
862
+ }
679
863
  Router.post(router, '/apps/:appId/events', publishEvent);
680
864
  };
681
865
  const socketRouteRegistrar = Object.freeze({
package/dist/register.js CHANGED
@@ -1,4 +1,12 @@
1
1
  import { socketRouteRegistrar, socketRuntime } from './index.js';
2
+ const registerViaGlobalFallback = () => {
3
+ const globalRegistry = globalThis;
4
+ globalRegistry.__zintrustSocketRuntimeRegistry = {
5
+ ...globalRegistry.__zintrustSocketRuntimeRegistry,
6
+ runtime: socketRuntime,
7
+ routeRegistrar: socketRouteRegistrar,
8
+ };
9
+ };
2
10
  const importCore = async () => {
3
11
  try {
4
12
  return (await import('@zintrust/core'));
@@ -8,7 +16,10 @@ const importCore = async () => {
8
16
  }
9
17
  };
10
18
  const core = await importCore();
11
- if (core.SocketRuntimeRegistry !== undefined) {
19
+ if (core.SocketRuntimeRegistry === undefined) {
20
+ registerViaGlobalFallback();
21
+ }
22
+ else {
12
23
  core.SocketRuntimeRegistry.registerRuntime(socketRuntime);
13
24
  core.SocketRuntimeRegistry.registerRoutes(socketRouteRegistrar);
14
25
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zintrust/socket",
3
- "version": "0.4.58",
3
+ "version": "0.4.59",
4
4
  "description": "Unified socket runtime for ZinTrust.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -23,7 +23,7 @@
23
23
  "node": ">=20.0.0"
24
24
  },
25
25
  "peerDependencies": {
26
- "@zintrust/core": "^0.4.58"
26
+ "@zintrust/core": "^0.4.59"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"