@zintrust/socket 0.4.58 → 0.4.61

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,14 +1,14 @@
1
1
  {
2
2
  "name": "@zintrust/socket",
3
- "version": "0.4.58",
4
- "buildDate": "2026-04-04T19:59:22.567Z",
3
+ "version": "0.4.61",
4
+ "buildDate": "2026-04-05T07:58:39.948Z",
5
5
  "buildEnvironment": {
6
6
  "node": "v22.22.1",
7
7
  "platform": "darwin",
8
8
  "arch": "arm64"
9
9
  },
10
10
  "git": {
11
- "commit": "99e4d331",
11
+ "commit": "b4dbe529",
12
12
  "branch": "release"
13
13
  },
14
14
  "package": {
@@ -21,21 +21,25 @@
21
21
  ]
22
22
  },
23
23
  "files": {
24
+ "build-manifest.json": {
25
+ "size": 1086,
26
+ "sha256": "a55e096a80190637fe7d56c36e53865451f623c88ceb5ccb7b9124e4a14335bb"
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": 35299,
34
+ "sha256": "112c1becf555e48ea7f0ca52b4e0588c764777cefc90c33dd8c8fe52715b54ce"
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,15 @@
1
- import { Cloudflare, isArray, isNonEmptyString, Router, SocketFeature, } from '@zintrust/core';
1
+ /**
2
+ * @zintrust/socket v0.4.60
3
+ *
4
+ * Unified socket runtime for ZinTrust.
5
+ *
6
+ * Build Information:
7
+ * Built: 2026-04-05T07:58:23.814Z
8
+ * Node: >=20.0.0
9
+ * License: MIT
10
+ *
11
+ */
12
+ import { Cloudflare, broadcastConfig, ErrorFactory, isArray, isNonEmptyString, Logger, middlewareConfig, Router, SocketFeature, } from '@zintrust/core';
2
13
  const encoder = new TextEncoder();
3
14
  const websocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
4
15
  const socketHubBindingName = 'ZT_SOCKET_HUB';
@@ -491,7 +502,7 @@ const shouldUseCloudflareHub = (settings) => {
491
502
  };
492
503
  const parseJsonResponse = async (response) => {
493
504
  try {
494
- return (await response.clone().json());
505
+ return await response.clone().json();
495
506
  }
496
507
  catch {
497
508
  try {
@@ -502,6 +513,114 @@ const parseJsonResponse = async (response) => {
502
513
  }
503
514
  }
504
515
  };
516
+ const isSocketAuthorizerHandler = (value) => {
517
+ return typeof value === 'function';
518
+ };
519
+ const isSocketAuthorizer = (value) => {
520
+ const candidate = value;
521
+ return typeof value === 'object' && value !== null && typeof candidate?.authorize === 'function';
522
+ };
523
+ const createDefaultSocketAuthorizer = () => {
524
+ return Object.freeze({
525
+ async authorize(_request, context) {
526
+ if (context.channelName.startsWith('private-') ||
527
+ context.channelName.startsWith('presence-')) {
528
+ return {
529
+ authorized: context.user !== null && context.user !== undefined,
530
+ };
531
+ }
532
+ return {
533
+ authorized: false,
534
+ };
535
+ },
536
+ });
537
+ };
538
+ const resolveSocketAuthorizer = () => {
539
+ const configured = broadcastConfig.socket.authorize;
540
+ if (configured === undefined) {
541
+ return createDefaultSocketAuthorizer();
542
+ }
543
+ if (isSocketAuthorizerHandler(configured)) {
544
+ return Object.freeze({
545
+ authorize: configured,
546
+ });
547
+ }
548
+ if (isSocketAuthorizer(configured)) {
549
+ return configured;
550
+ }
551
+ throw ErrorFactory.createConfigError('broadcastConfig.socket.authorize must be a function or an object with an authorize(request, context) method.');
552
+ };
553
+ const isSocketPublishPolicyHandler = (value) => {
554
+ return typeof value === 'function';
555
+ };
556
+ const isSocketPublishPolicy = (value) => {
557
+ const candidate = value;
558
+ return typeof value === 'object' && value !== null && typeof candidate?.authorize === 'function';
559
+ };
560
+ const createDefaultSocketPublishPolicy = () => {
561
+ return Object.freeze({
562
+ async authorize() {
563
+ return {
564
+ allowed: true,
565
+ };
566
+ },
567
+ });
568
+ };
569
+ const resolveSocketPublishPolicy = () => {
570
+ const configured = broadcastConfig.socket.publish;
571
+ if (configured === undefined) {
572
+ return createDefaultSocketPublishPolicy();
573
+ }
574
+ if (isSocketPublishPolicyHandler(configured)) {
575
+ return Object.freeze({
576
+ authorize: configured,
577
+ });
578
+ }
579
+ if (isSocketPublishPolicy(configured)) {
580
+ return configured;
581
+ }
582
+ throw ErrorFactory.createConfigError('broadcastConfig.socket.publish must be a function or an object with an authorize(request, context) method.');
583
+ };
584
+ const resolveSocketAuthMiddleware = () => {
585
+ const configured = broadcastConfig.socket.authMiddleware
586
+ .filter(isNonEmptyString)
587
+ .map((value) => value.trim())
588
+ .filter((value) => value !== '');
589
+ const middleware = configured.length > 0 ? configured : ['auth'];
590
+ const known = new Set(Object.keys(middlewareConfig.route));
591
+ const unknown = middleware.filter((value) => !known.has(value));
592
+ if (unknown.length > 0) {
593
+ throw ErrorFactory.createConfigError(`Unknown socket auth middleware configured: ${unknown.join(', ')}`);
594
+ }
595
+ return middleware;
596
+ };
597
+ const isSocketAuthRouteOverrideEnabled = () => {
598
+ return broadcastConfig.socket.allowAuthRouteOverride === true;
599
+ };
600
+ const routeExists = (router, method, path) => {
601
+ return router.routes.some((route) => route.method === method && route.path === path);
602
+ };
603
+ const assertReservedSocketRouteAvailable = (router, method, path, options) => {
604
+ if (!routeExists(router, method, path)) {
605
+ return;
606
+ }
607
+ if (options?.allowOverride === true && isSocketAuthRouteOverrideEnabled()) {
608
+ return;
609
+ }
610
+ throw ErrorFactory.createConfigError(`Socket compatibility route ${method} ${path} is reserved while sockets are enabled.`);
611
+ };
612
+ const createSocketAuthPayload = async (settings, socketId, channelName, channelData) => {
613
+ if (settings.secret.trim() === '' || settings.appKey.trim() === '') {
614
+ throw ErrorFactory.createConfigError('Socket auth is not configured.');
615
+ }
616
+ const signature = await hmacSha256Hex(settings.secret, channelData === undefined
617
+ ? `${socketId}:${channelName}`
618
+ : `${socketId}:${channelName}:${channelData}`);
619
+ return {
620
+ auth: `${settings.appKey}:${signature}`,
621
+ ...(channelData === undefined ? {} : { channel_data: channelData }),
622
+ };
623
+ };
505
624
  const parsePublishPayload = (payload) => {
506
625
  let event = '';
507
626
  if (isNonEmptyString(payload.name)) {
@@ -527,6 +646,29 @@ const parsePublishPayload = (payload) => {
527
646
  ...(isNonEmptyString(payload.socket_id) ? { socket_id: payload.socket_id.trim() } : {}),
528
647
  };
529
648
  };
649
+ const normalizePublishDecisionPayload = (payload, decision) => {
650
+ const event = isNonEmptyString(decision.event) ? decision.event.trim() : payload.event;
651
+ const channels = isArray(decision.channels)
652
+ ? decision.channels.filter(isNonEmptyString).map((item) => item.trim())
653
+ : payload.channels;
654
+ if (event === '' || channels.length === 0) {
655
+ return null;
656
+ }
657
+ return {
658
+ channels,
659
+ event,
660
+ data: decision.data === undefined ? payload.data : decision.data,
661
+ ...(() => {
662
+ if (isNonEmptyString(decision.socketId)) {
663
+ return { socket_id: decision.socketId.trim() };
664
+ }
665
+ if (payload.socket_id === undefined) {
666
+ return {};
667
+ }
668
+ return { socket_id: payload.socket_id };
669
+ })(),
670
+ };
671
+ };
530
672
  const forwardPublishToHub = async (settings, payload, envSource) => {
531
673
  const stub = getSocketHubStub(settings, envSource);
532
674
  if (stub === null) {
@@ -612,10 +754,6 @@ const respondUpgradeRequired = (req, res) => {
612
754
  };
613
755
  const authenticateSubscription = async (req, res) => {
614
756
  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
757
  const body = req.getBody();
620
758
  const payload = body !== null && typeof body === 'object' ? body : {};
621
759
  const socketId = isNonEmptyString(payload['socket_id']) ? payload['socket_id'].trim() : '';
@@ -629,13 +767,28 @@ const authenticateSubscription = async (req, res) => {
629
767
  res.setStatus(400).json({ error: 'socket_id and channel_name are required.' });
630
768
  return;
631
769
  }
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 }),
770
+ const authorizer = resolveSocketAuthorizer();
771
+ const decision = await authorizer.authorize(req, {
772
+ channelName,
773
+ socketId,
774
+ user: req.user ?? null,
775
+ channelData,
638
776
  });
777
+ if (decision.authorized !== true) {
778
+ res.setStatus(403).json({ message: 'Forbidden' });
779
+ return;
780
+ }
781
+ const resolvedChannelData = isNonEmptyString(decision.channelData)
782
+ ? decision.channelData.trim()
783
+ : channelData;
784
+ if (isNonEmptyString(decision.auth)) {
785
+ res.json({
786
+ auth: decision.auth.trim(),
787
+ ...(resolvedChannelData === undefined ? {} : { channel_data: resolvedChannelData }),
788
+ });
789
+ return;
790
+ }
791
+ res.json(await createSocketAuthPayload(settings, socketId, channelName, resolvedChannelData));
639
792
  };
640
793
  const publishEvent = async (req, res) => {
641
794
  const settings = getSocketRuntimeSettings();
@@ -658,24 +811,64 @@ const publishEvent = async (req, res) => {
658
811
  res.setStatus(400).json({ error: 'event/name and channel/channels are required.' });
659
812
  return;
660
813
  }
814
+ const publishPolicy = resolveSocketPublishPolicy();
815
+ const decision = await publishPolicy.authorize(req, {
816
+ appId: appId.trim(),
817
+ channels: normalizedPayload.channels,
818
+ event: normalizedPayload.event,
819
+ data: normalizedPayload.data,
820
+ socketId: normalizedPayload.socket_id,
821
+ user: req.user ?? null,
822
+ });
823
+ if (decision.allowed !== true) {
824
+ res.setStatus(decision.statusCode ?? 403).json({
825
+ message: decision.message ?? 'Forbidden',
826
+ });
827
+ return;
828
+ }
829
+ const allowedPayload = normalizePublishDecisionPayload(normalizedPayload, decision);
830
+ if (allowedPayload === null) {
831
+ res
832
+ .setStatus(400)
833
+ .json({ error: 'publish policy must return a non-empty event and channels.' });
834
+ return;
835
+ }
661
836
  if (shouldUseCloudflareHub(settings)) {
662
- const response = await forwardPublishToHub(settings, normalizedPayload, Cloudflare.getWorkersEnv());
837
+ const response = await forwardPublishToHub(settings, allowedPayload, Cloudflare.getWorkersEnv());
663
838
  const responseBody = await parseJsonResponse(response);
664
839
  res.setStatus(response.status).json(responseBody);
665
840
  return;
666
841
  }
667
- const deliveries = publishToChannels(getNodeSocketState(), normalizedPayload.channels, normalizedPayload.event, normalizedPayload.data, normalizedPayload.socket_id);
842
+ const deliveries = publishToChannels(getNodeSocketState(), allowedPayload.channels, allowedPayload.event, allowedPayload.data, allowedPayload.socket_id);
668
843
  res.setStatus(202).json({
669
844
  ok: true,
670
- channels: normalizedPayload.channels,
671
- event: normalizedPayload.event,
845
+ channels: allowedPayload.channels,
846
+ event: allowedPayload.event,
672
847
  deliveries,
673
848
  });
674
849
  };
675
850
  const registerSocketRoutes = (router) => {
676
851
  const settings = getSocketRuntimeSettings();
852
+ const allowAuthRouteOverride = isSocketAuthRouteOverrideEnabled();
853
+ const hasExistingAuthRoute = routeExists(router, 'POST', '/broadcasting/auth');
854
+ assertReservedSocketRouteAvailable(router, 'GET', `${settings.path}/:appKey`);
855
+ assertReservedSocketRouteAvailable(router, 'POST', '/apps/:appId/events');
677
856
  Router.get(router, `${settings.path}/:appKey`, respondUpgradeRequired);
678
- Router.post(router, '/broadcasting/auth', authenticateSubscription);
857
+ if (hasExistingAuthRoute) {
858
+ if (!allowAuthRouteOverride) {
859
+ Logger.info('Detected existing application-owned POST /broadcasting/auth route; preserving it while sockets are enabled.');
860
+ }
861
+ }
862
+ else {
863
+ Router.post(router, '/broadcasting/auth', authenticateSubscription, {
864
+ middleware: resolveSocketAuthMiddleware(),
865
+ meta: {
866
+ summary: 'Socket broadcast authorization',
867
+ tags: ['Sockets'],
868
+ responseStatus: 200,
869
+ },
870
+ });
871
+ }
679
872
  Router.post(router, '/apps/:appId/events', publishEvent);
680
873
  };
681
874
  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.61",
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.61"
27
27
  },
28
28
  "publishConfig": {
29
29
  "access": "public"
@@ -34,8 +34,5 @@
34
34
  "websocket",
35
35
  "broadcast"
36
36
  ],
37
- "scripts": {
38
- "build": "tsc -p tsconfig.json",
39
- "prepublishOnly": "npm run build"
40
- }
41
- }
37
+ "scripts": {}
38
+ }