@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 +44 -0
- package/dist/build-manifest.json +10 -6
- package/dist/index.js +200 -16
- package/dist/register.js +12 -1
- package/package.json +2 -2
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
|
package/dist/build-manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zintrust/socket",
|
|
3
|
-
"version": "0.4.
|
|
4
|
-
"buildDate": "2026-04-
|
|
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":
|
|
30
|
-
"sha256": "
|
|
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":
|
|
38
|
-
"sha256": "
|
|
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
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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,
|
|
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(),
|
|
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:
|
|
671
|
-
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
26
|
+
"@zintrust/core": "^0.4.59"
|
|
27
27
|
},
|
|
28
28
|
"publishConfig": {
|
|
29
29
|
"access": "public"
|