@cocreate/socket 0.0.0-stage → 3.0.1

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.
@@ -0,0 +1,149 @@
1
+ /** Map-like views over the Socket Server's existing lifecycle and indexes. */
2
+ class Collection {
3
+ #source;
4
+ #value;
5
+
6
+ constructor(source, value = value => value) {
7
+ this.#source = source;
8
+ this.#value = value;
9
+ }
10
+
11
+ get size() { return this.#source()?.size || 0; }
12
+ has(id) { return this.#source()?.has(id) || false; }
13
+ get(id) {
14
+ const value = this.#source()?.get(id);
15
+ return value === undefined ? undefined : this.#value(value, id);
16
+ }
17
+ *keys() { yield* this.#source()?.keys() || []; }
18
+ *values() { for (const id of this.keys()) yield this.get(id); }
19
+ *entries() { for (const id of this.keys()) yield [id, this.get(id)]; }
20
+ [Symbol.iterator]() { return this.entries(); }
21
+ forEach(callback, thisArg) {
22
+ for (const [id, value] of this) callback.call(thisArg, value, id, this);
23
+ }
24
+ clear() { for (const id of [...this.keys()]) this.delete(id); }
25
+ }
26
+
27
+ // Metadata updates preserve references and cannot replace transport or index fields.
28
+ const metadataFields = new WeakMap();
29
+ function update(record, updates, mutable = [], protectedFields = []) {
30
+ if (!record || !updates || typeof updates !== 'object') return;
31
+ if (!metadataFields.has(record)) metadataFields.set(record, new Set(mutable));
32
+ const allowed = metadataFields.get(record);
33
+ for (const [key, value] of Object.entries(updates)) {
34
+ if (key.startsWith('_') || key in Object.prototype || protectedFields.includes(key)) continue;
35
+ if (key in record && !allowed.has(key)) continue;
36
+ allowed.add(key);
37
+ Object.defineProperty(record, key, { value, writable: true, enumerable: true, configurable: true });
38
+ }
39
+ }
40
+
41
+ class ClientSockets extends Collection {
42
+ constructor(root, clientId, organizationId) {
43
+ super(() => organizationId === undefined
44
+ ? root.manager.clients.get(clientId)
45
+ : root.manager.organizations.get(organizationId)?.clients.get(clientId));
46
+ Object.defineProperties(this, {
47
+ root: { value: root },
48
+ clientId: { value: clientId },
49
+ organizationId: { value: organizationId }
50
+ });
51
+ }
52
+ add(socket) {
53
+ if (socket.clientId !== this.clientId ||
54
+ (this.organizationId !== undefined && socket.organization_id !== this.organizationId)) {
55
+ throw new Error('Socket does not belong to this client');
56
+ }
57
+ return this.root.add(socket);
58
+ }
59
+ set(id, updates) { if (this.has(id)) this.root.set(id, updates); }
60
+ delete(id) { if (this.has(id)) this.root.delete(id); }
61
+ }
62
+
63
+ class Clients extends Collection {
64
+ constructor(root, organizationId) {
65
+ const views = new WeakMap();
66
+ super(() => organizationId === undefined ? root.manager.clients
67
+ : root.manager.organizations.get(organizationId)?.clients,
68
+ (sockets, id) => {
69
+ if (!views.has(sockets)) views.set(sockets, new ClientSockets(root, id, organizationId));
70
+ return views.get(sockets);
71
+ });
72
+ Object.defineProperties(this, {
73
+ root: { value: root },
74
+ organizationId: { value: organizationId }
75
+ });
76
+ }
77
+ add(socket) {
78
+ if (this.organizationId !== undefined && socket.organization_id !== this.organizationId) {
79
+ throw new Error('Socket does not belong to this organization');
80
+ }
81
+ this.root.add(socket);
82
+ return this.get(socket.clientId);
83
+ }
84
+ delete(id) { this.get(id)?.clear(); }
85
+ }
86
+
87
+ class Organizations extends Collection {
88
+ constructor(root) {
89
+ const views = new WeakMap();
90
+ super(() => root.manager.organizations, (organization, id) => {
91
+ if (!views.has(organization)) {
92
+ const clients = new Clients(root, id);
93
+ views.set(organization, new Proxy(organization, {
94
+ get(target, key, receiver) {
95
+ return key === 'clients' ? clients : Reflect.get(target, key, receiver);
96
+ }
97
+ }));
98
+ }
99
+ return views.get(organization);
100
+ });
101
+ Object.defineProperty(this, 'root', { value: root });
102
+ }
103
+ add(socket) {
104
+ this.root.add(socket);
105
+ return this.get(socket.organization_id);
106
+ }
107
+ set(id, updates) { update(this.root.manager.organizations.get(id), updates, ['status', 'organizationBalance']); }
108
+ delete(id) { this.root.manager.destroyOrganizations(id); }
109
+ }
110
+
111
+ /**
112
+ * Public socket collection. Add accepts an authenticated socket with id,
113
+ * clientId and organization_id; delete closes its transport and cleans indexes.
114
+ * Client and organization add methods accept the same socket and return their record.
115
+ */
116
+ export default class Sockets extends Collection {
117
+ constructor(manager) {
118
+ super(() => manager.sockets);
119
+ Object.defineProperty(this, 'manager', { value: manager });
120
+ this.clients = new Clients(this);
121
+ this.organizations = new Organizations(this);
122
+ }
123
+ add(socket) {
124
+ if (!socket?.id || !socket.clientId || !socket.organization_id) {
125
+ throw new TypeError('Socket requires id, clientId and organization_id');
126
+ }
127
+ const existing = this.get(socket.id);
128
+ if (existing === socket) return socket;
129
+ if (existing) throw new Error(`Socket ${socket.id} already exists`);
130
+ this.manager.onWebSocket(socket);
131
+ return socket;
132
+ }
133
+ set(id, updates) {
134
+ update(this.get(id), updates, ['clientInfo'], [
135
+ 'id', 'clientId', 'organization_id', 'user_id', 'subscriptionKeys',
136
+ 'bypassDeleteCascade', 'expires', 'isAlive', 'readyState',
137
+ 'host', 'origin', 'pathname', 'socketUrl'
138
+ ]);
139
+ }
140
+ delete(id) {
141
+ const socket = this.get(id);
142
+ if (!socket) return;
143
+ try { socket.close(1000); }
144
+ finally {
145
+ if (this.get(id) === socket) this.manager.delete(socket);
146
+ }
147
+ }
148
+ send(data) { return this.manager.send(data); }
149
+ }
@@ -0,0 +1,84 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { WebSocketServer } from 'ws';
4
+ import { SocketClient } from '../../src/client/index.js';
5
+
6
+ test('client indexes the same socket by URL and ID and deletes both references', () => {
7
+ const socket = {
8
+ id: 'indexed-socket', url: 'ws://localhost/indexed-test', readyState: 1,
9
+ close() { this.readyState = 3; }
10
+ };
11
+ try {
12
+ SocketClient.set(socket);
13
+ assert.equal(SocketClient.get(socket.id), socket);
14
+ assert.equal(SocketClient.get(socket.url), socket);
15
+ SocketClient.delete(socket.id);
16
+ assert.equal(socket.readyState, 3);
17
+ assert.equal(SocketClient.has(socket.id), false);
18
+ assert.equal(SocketClient.has(socket.url), false);
19
+ } finally {
20
+ SocketClient.delete(socket.url);
21
+ }
22
+ });
23
+
24
+ for (const denied of [false, true]) {
25
+ test(`client waits for the ready frame and ${denied ? 'returns the error envelope' : 'resolves its correlated response'}`, { timeout: 5000 }, async t => {
26
+ await SocketClient.init();
27
+ if (denied) t.mock.method(console, 'error', () => {});
28
+ const server = new WebSocketServer({ port: 0, host: '127.0.0.1' });
29
+ await new Promise(resolve => server.once('listening', resolve));
30
+ const host = `ws://127.0.0.1:${server.address().port}`;
31
+ const organization_id = `client-test-${denied}`;
32
+ const url = `${host}/${organization_id}`;
33
+ t.after(async () => {
34
+ const socket = SocketClient.get(url);
35
+ if (socket) {
36
+ // Teardown must not start a reconnect cycle after the test.
37
+ socket.onclose = null;
38
+ socket.onerror = null;
39
+ socket.terminate();
40
+ SocketClient.delete(url);
41
+ }
42
+ for (const socket of server.clients) socket.terminate();
43
+ await new Promise(resolve => server.close(resolve));
44
+ });
45
+ const requests = [];
46
+ let earlyRequests = 0;
47
+ server.on('connection', socket => {
48
+ let ready = false;
49
+ const timer = setTimeout(() => {
50
+ ready = true;
51
+ socket.send(JSON.stringify({ method: 'connect', connectedKey: `/${organization_id}` }));
52
+ }, 30);
53
+ socket.on('close', () => clearTimeout(timer));
54
+ socket.on('message', message => {
55
+ if (!ready) earlyRequests++;
56
+ const request = JSON.parse(message);
57
+ requests.push(request);
58
+ socket.send(JSON.stringify({
59
+ ...request, broadcastSender: false,
60
+ ...(denied ? { error: 'read denied' } : { object: [{ _id: 'local-test' }] })
61
+ }));
62
+ });
63
+ });
64
+ const pending = SocketClient.send({
65
+ method: 'object.read', host, organization_id,
66
+ array: 'socket_test', broadcast: false
67
+ });
68
+ const response = await pending;
69
+ if (denied) {
70
+ assert.equal(response.error, 'read denied');
71
+ } else {
72
+ assert.deepEqual(response.object, [{ _id: 'local-test' }]);
73
+ }
74
+ assert.equal(response.uuid, requests[0].uuid);
75
+ assert.equal(earlyRequests, 0);
76
+ assert.equal(requests.length, 1);
77
+ assert.equal(requests[0].organization_id, organization_id);
78
+ assert.equal(requests[0].clientId, SocketClient.clientId);
79
+ assert.equal(requests[0].frameId, SocketClient.frameId);
80
+ assert.equal(typeof requests[0].uuid, 'string');
81
+ const socket = SocketClient.get(url);
82
+ assert.equal(SocketClient.get(socket.id), socket);
83
+ });
84
+ }
@@ -0,0 +1,111 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+ import { SocketServer } from '../../src/server/index.js';
5
+ import Sockets from '../../src/server/sockets.js';
6
+
7
+ function fixture(t) {
8
+ t.mock.timers.enable({ apis: ['setTimeout'] });
9
+ const manager = new EventEmitter();
10
+ Object.setPrototypeOf(manager, SocketServer.prototype);
11
+ Object.assign(manager, {
12
+ server: {}, workerId: 1, organizations: new Map(), organizationBalances: new Map(),
13
+ clients: new Map(), sockets: new Map(), users: new Map()
14
+ });
15
+ const sockets = new Sockets(manager);
16
+ function socket(id, clientId = 'client', organization_id = 'org') {
17
+ const socket = new EventEmitter();
18
+ Object.assign(socket, {
19
+ id, clientId, organization_id, readyState: 1,
20
+ send() {}, close(code) { this.closeCode = code; this.emit('close'); },
21
+ terminate() { this.emit('close'); }
22
+ });
23
+ return socket;
24
+ }
25
+ return { manager, sockets, socket };
26
+ }
27
+
28
+ test('socket collection shares indexes, protects identity, and preserves metadata references', t => {
29
+ const f = fixture(t);
30
+ const socket = f.socket('one');
31
+ assert.equal(f.sockets.add(socket), socket);
32
+ assert.equal(f.sockets.add(socket), socket);
33
+ assert.equal(f.sockets.size, 1);
34
+ f.sockets.set('one', { label: 'first', id: 'wrong', clientId: 'wrong', user_id: 'wrong', subscriptionKeys: new Set(), close: null });
35
+ f.sockets.set('one', { label: 'second' });
36
+ assert.equal(socket.label, 'second');
37
+ assert.equal(socket.id, 'one');
38
+ assert.equal(socket.clientId, 'client');
39
+ assert.equal(socket.user_id, undefined);
40
+ assert.equal(socket.subscriptionKeys, undefined);
41
+ assert.equal(typeof socket.close, 'function');
42
+ assert.equal(f.manager.sockets.get('one'), socket);
43
+ assert.deepEqual([...f.sockets], [['one', socket]]);
44
+ assert.equal(f.sockets.clients.get('client').get('one'), socket);
45
+ assert.equal(f.sockets.organizations.get('org').clients.get('client').get('one'), socket);
46
+ assert.throws(() => f.sockets.add(f.socket('one')), /already exists/);
47
+ assert.equal(f.sockets.size, 1);
48
+ });
49
+
50
+ test('nested client deletion cleans subscriptions without affecting another organization', t => {
51
+ const f = fixture(t);
52
+ const first = f.socket('one');
53
+ const other = f.socket('two', 'client', 'other');
54
+ f.sockets.clients.add(first);
55
+ const org = f.sockets.organizations.add(other);
56
+ f.manager.subscribe(first, 'updates');
57
+ assert.equal(org, f.sockets.organizations.get('other'));
58
+ const clients = f.sockets.organizations.get('org').clients;
59
+ assert.throws(() => clients.add(f.socket('wrong', 'client', 'other')), /does not belong/);
60
+ clients.delete('client');
61
+ assert.equal(first.closeCode, 1000);
62
+ assert.equal(f.sockets.get('one'), undefined);
63
+ assert.equal(f.sockets.get('two'), other);
64
+ assert.equal(f.sockets.clients.get('client').size, 1);
65
+ assert.equal(f.sockets.organizations.get('org').subscriptions.size, 0);
66
+ t.mock.timers.tick(60000);
67
+ assert.equal(f.sockets.organizations.has('org'), false);
68
+ assert.equal(f.sockets.organizations.has('other'), true);
69
+ });
70
+
71
+ test('organization deletion closes all its sockets and emits one disconnect', t => {
72
+ const f = fixture(t);
73
+ const events = [];
74
+ const listener = id => events.push(id);
75
+ process.on('orgDisconnected', listener);
76
+ t.after(() => process.off('orgDisconnected', listener));
77
+ f.sockets.add(f.socket('one'));
78
+ f.sockets.add(f.socket('two', 'other-client'));
79
+ f.sockets.organizations.delete('org');
80
+ assert.equal(f.sockets.size, 0);
81
+ assert.equal(f.sockets.clients.size, 0);
82
+ assert.equal(f.sockets.organizations.size, 0);
83
+ f.sockets.organizations.delete('org');
84
+ t.mock.timers.tick(60000);
85
+ assert.deepEqual(events, ['org']);
86
+ });
87
+
88
+ test('send delegates to the existing delivery implementation', t => {
89
+ const f = fixture(t);
90
+ const payload = { organization_id: 'org', method: 'object.read' };
91
+ const result = Promise.resolve('sent');
92
+ t.mock.method(f.manager, 'send', data => { assert.equal(data, payload); return result; });
93
+ assert.equal(f.sockets.send(payload), result);
94
+ });
95
+
96
+ test('natural close and reconnect retain one presence transition through the grace period', t => {
97
+ const f = fixture(t);
98
+ const events = [];
99
+ const listener = id => events.push(id);
100
+ process.on('orgConnected', listener);
101
+ t.after(() => process.off('orgConnected', listener));
102
+ const first = f.socket('one');
103
+ f.sockets.add(first);
104
+ first.emit('close');
105
+ assert.equal(f.sockets.size, 0);
106
+ f.sockets.add(f.socket('two'));
107
+ t.mock.timers.tick(60000);
108
+ assert.deepEqual(events, ['org']);
109
+ assert.equal(f.sockets.organizations.size, 1);
110
+ assert.equal(f.sockets.organizations.get('org').clients.get('client').size, 1);
111
+ });
@@ -0,0 +1,257 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { EventEmitter } from 'node:events';
4
+ import { SocketServer } from '../../src/server/index.js';
5
+
6
+ function fixture() {
7
+ const manager = new EventEmitter();
8
+ Object.setPrototypeOf(manager, SocketServer.prototype);
9
+ const attempts = [];
10
+ const usage = [];
11
+ const clients = new Map();
12
+ const subscriptions = new Map([['updates', new Set()]]);
13
+ manager.organizations = new Map([['org', { clients, subscriptions }]]);
14
+ manager.sockets = new Map();
15
+ manager.server = { usage: { send: (data) => usage.push(data) }, send() {} };
16
+ function socket(id, clientId = 'client', failure) {
17
+ const socket = {
18
+ id, clientId, organization_id: 'org', readyState: 1,
19
+ send(payload, callback) {
20
+ attempts.push(id);
21
+ if (failure === 'throw') throw new Error('write failed');
22
+ setImmediate(() => callback(failure ? new Error('write failed') : undefined));
23
+ }
24
+ };
25
+ if (!clients.has(clientId)) clients.set(clientId, new Map());
26
+ clients.get(clientId).set(id, socket);
27
+ manager.sockets.set(id, socket);
28
+ return socket;
29
+ }
30
+ async function send(extra = {}) {
31
+ let notification;
32
+ manager.once('notification', (data) => { notification = data; });
33
+ await manager.send({ method: 'object.read', organization_id: 'org', ...extra });
34
+ return notification.sent;
35
+ }
36
+ return { manager, attempts, usage, clients, subscriptions, socket, send };
37
+ }
38
+
39
+ test('requesting socket wins over older tabs, with one write per client', async () => {
40
+ const f = fixture();
41
+ f.socket('older');
42
+ const origin = f.socket('origin');
43
+ f.socket('other', 'other-client');
44
+ assert.deepEqual(await f.send({ socketId: origin.id, broadcast: true }), ['client', 'other-client']);
45
+ assert.deepEqual(f.attempts, ['origin', 'other']);
46
+ assert.equal(f.usage.length, 2);
47
+ });
48
+
49
+ for (const failure of ['closed', 'callback', 'throw']) {
50
+ test(`falls back after ${failure} failure and stops after success`, async (t) => {
51
+ t.mock.method(console, 'error', () => {});
52
+ const f = fixture();
53
+ f.socket('fallback');
54
+ f.socket('unused');
55
+ const origin = f.socket('origin', 'client', failure);
56
+ if (failure === 'closed') origin.readyState = 3;
57
+ assert.deepEqual(await f.send({ socketId: origin.id }), ['client']);
58
+ assert.deepEqual(f.attempts, failure === 'closed' ? ['fallback'] : ['origin', 'fallback']);
59
+ assert.equal(f.usage.length, 1);
60
+ });
61
+ }
62
+
63
+ test('subscription delivery includes the origin and uses other tabs as fallback', async () => {
64
+ const f = fixture();
65
+ f.socket('fallback');
66
+ const origin = f.socket('origin', 'client', 'callback');
67
+ f.socket('subscriber', 'other-client');
68
+ f.socket('unrelated', 'unrelated-client');
69
+ f.subscriptions.get('updates').add('subscriber');
70
+ assert.deepEqual(await f.send({ socketId: origin.id, subscriptionKeys: ['updates'] }), ['other-client', 'client']);
71
+ assert.deepEqual(f.attempts, ['subscriber', 'origin', 'fallback']);
72
+ });
73
+
74
+ test('subscription ordering cannot select an older tab before the origin', async () => {
75
+ const f = fixture();
76
+ f.socket('older');
77
+ const origin = f.socket('origin');
78
+ f.subscriptions.get('updates').add('older');
79
+ f.subscriptions.get('updates').add('origin');
80
+ await f.send({ socketId: origin.id, subscriptionKeys: ['updates'] });
81
+ assert.deepEqual(f.attempts, ['origin']);
82
+ });
83
+
84
+ test('failed writes are not counted as delivery', async () => {
85
+ const f = fixture();
86
+ const origin = f.socket('origin', 'client', 'callback');
87
+ f.socket('fallback', 'client', 'callback');
88
+ assert.deepEqual(await f.send({ socketId: origin.id }), []);
89
+ assert.equal(f.usage.length, 0);
90
+ });
91
+
92
+ test('direct client ID delivery also stops after the first success', async () => {
93
+ const f = fixture();
94
+ f.socket('first');
95
+ f.socket('unused');
96
+ assert.deepEqual(await f.send({ clientId: 'client' }), ['client']);
97
+ assert.deepEqual(f.attempts, ['first']);
98
+ });
99
+
100
+ test('socket payload data is preserved without participating in recipient selection', async () => {
101
+ const f = fixture();
102
+ const origin = f.socket('origin', 'client');
103
+ f.socket('other', 'other-client');
104
+ let delivered;
105
+ const write = origin.send;
106
+ origin.send = (payload, callback) => { delivered = JSON.parse(payload); write(payload, callback); };
107
+ const socket = { id: 'other', organization_id: 'another-org', purpose: 'application data' };
108
+ await f.send({ socketId: 'origin', socket });
109
+ assert.deepEqual(f.attempts, ['origin']);
110
+ assert.deepEqual(delivered.socket, socket);
111
+ });
112
+
113
+ for (const lookup of ['no matches', 'no subscriptions', 'no organization', 'unregistered broadcast']) {
114
+ test(`requester remains a recipient with ${lookup}`, async () => {
115
+ const f = fixture();
116
+ const origin = f.socket('origin');
117
+ const data = { socketId: origin.id, subscriptionKeys: ['missing'] };
118
+ if (lookup === 'no subscriptions') {
119
+ delete f.manager.organizations.get('org').subscriptions;
120
+ } else if (lookup === 'no organization') {
121
+ f.manager.organizations.clear();
122
+ } else if (lookup === 'unregistered broadcast') {
123
+ f.clients.clear();
124
+ delete data.subscriptionKeys;
125
+ data.broadcast = true;
126
+ }
127
+ assert.deepEqual(await f.send(data), ['client']);
128
+ assert.deepEqual(f.attempts, ['origin']);
129
+ });
130
+ }
131
+
132
+ test('socket send is local-only, resolves undefined, and preserves the routing envelope', async () => {
133
+ const f = fixture();
134
+ const socket = f.socket('one');
135
+ Object.assign(f.manager.server, { id: 'node', workerId: 1, cluster_id: 'local-cluster' });
136
+ const write = socket.send;
137
+ socket.send = (payload, callback) => {
138
+ const message = JSON.parse(payload);
139
+ for (const field of ['serverId', 'workerId', 'clusterId']) assert.equal(field in message, false);
140
+ write(payload, callback);
141
+ };
142
+ f.manager.server.send = () => assert.fail('Socket transport must never call server.send');
143
+ const data = { method: 'object.read', organization_id: 'org', clientId: 'client',
144
+ filter: { query: { _id: { $eq: '$user_id' } } } };
145
+ assert.equal(await f.manager.send(data), undefined);
146
+ assert.deepEqual(data.filter.query, { _id: { $eq: '$user_id' } });
147
+ assert.deepEqual(f.attempts, ['one']);
148
+ });
149
+
150
+ test('incoming cursor dispatch enters server.send instead of raw socket delivery', async () => {
151
+ const f = fixture();
152
+ const socket = f.socket('one');
153
+ const routed = [];
154
+ f.manager.server.send = data => routed.push(data);
155
+ f.manager.send = () => assert.fail('Incoming component messages must enter server.send');
156
+ const data = { method: 'cursor', organization_id: 'org' };
157
+ await f.manager.Message(socket, data);
158
+ assert.deepEqual(routed, [data]);
159
+ });
160
+
161
+ test('incoming messages replace client-supplied identity with accepted socket values', async () => {
162
+ const f = fixture();
163
+ const socket = f.socket('trusted-socket', 'trusted-client');
164
+ Object.assign(socket, { user_id: 'trusted-user', host: 'trusted.example' });
165
+ const received = [];
166
+ f.manager.authorize = {
167
+ check: async (data, userId) => {
168
+ received.push({ data, userId });
169
+ return true;
170
+ }
171
+ };
172
+ f.manager.on('api', data => received.push({ routed: data }));
173
+ const data = {
174
+ api: 'example',
175
+ user_id: 'forged-user',
176
+ socketId: 'forged-socket',
177
+ clientId: 'forged-client',
178
+ organization_id: 'forged-org',
179
+ host: 'forged.example',
180
+ socket: { user_id: 'forged-socket-user', id: 'forged-socket' },
181
+ replyOrganizationId: 'forged-org'
182
+ };
183
+
184
+ await f.manager.Message(socket, data);
185
+
186
+ assert.equal(received[0].userId, 'trusted-user');
187
+ assert.equal(received[0].data.user_id, 'trusted-user');
188
+ assert.equal(received[0].data.socketId, 'trusted-socket');
189
+ assert.equal(received[0].data.clientId, 'trusted-client');
190
+ assert.equal(received[0].data.organization_id, 'org');
191
+ assert.equal(received[0].data.replyOrganizationId, 'org');
192
+ assert.equal(received[0].data.host, 'trusted.example');
193
+ assert.deepEqual(received[0].data.socket, { user_id: 'forged-socket-user', id: 'forged-socket' });
194
+ assert.equal(received[1].routed, data);
195
+ assert.equal(received[1].routed.socket, data.socket);
196
+ });
197
+
198
+ test('incoming unauthenticated messages cannot retain a supplied user ID', async () => {
199
+ const f = fixture();
200
+ const socket = f.socket('anonymous-socket', 'anonymous-client');
201
+ const received = [];
202
+ f.manager.authorize = {
203
+ check: async (data, userId) => {
204
+ received.push({ data, userId });
205
+ return true;
206
+ }
207
+ };
208
+ f.manager.on('api', data => received.push({ routed: data }));
209
+ const data = { api: 'example', user_id: 'forged-user' };
210
+
211
+ await f.manager.Message(socket, data);
212
+
213
+ assert.equal(received[0].userId, undefined);
214
+ assert.equal('user_id' in received[0].data, false);
215
+ assert.equal(received[0].data.socketId, 'anonymous-socket');
216
+ assert.equal(received[0].data.clientId, 'anonymous-client');
217
+ assert.equal(received[0].data.organization_id, 'org');
218
+ });
219
+
220
+ test('activity records terminate their own logging flow', async () => {
221
+ const f = fixture();
222
+ const activities = [];
223
+ const pending = [];
224
+ f.manager.on('object.create', request => {
225
+ activities.push(request);
226
+ // Bound a regression instead of allowing it to recurse indefinitely.
227
+ if (activities.length < 3) pending.push(f.manager.send({ ...request, object: [{ _id: 'activity' }] }));
228
+ });
229
+ await f.manager.send({ method: 'object.create', array: 'users', organization_id: 'org', object: [{ _id: 'user' }] });
230
+ await Promise.all(pending);
231
+ assert.equal(activities.length, 1);
232
+ assert.equal(activities[0].log, false);
233
+ await f.manager.send({ method: 'object.create', array: 'activities', organization_id: 'org', object: [{ _id: 'manual-activity' }] });
234
+ assert.equal(activities.length, 1, 'Activity writes do not generate more activity writes');
235
+ });
236
+
237
+ test('storage authorization fallback preserves the trusted tenant reply scope', async () => {
238
+ const f = fixture();
239
+ const socket = f.socket('socket');
240
+ f.manager.authorize = { check: async data => data.organization_id === 'org' ? { serverStorage: false } : true };
241
+ let received;
242
+ f.manager.on('object.read', data => { received = data; });
243
+ const previous = process.env.organization_id;
244
+ process.env.organization_id = 'platform';
245
+ try {
246
+ await f.manager.Message(socket, {
247
+ method: 'object.read', array: 'files', replyOrganizationId: 'forged-org'
248
+ });
249
+ assert.equal(received.organization_id, 'platform');
250
+ assert.equal(received.database, 'platform');
251
+ assert.equal(received.replyOrganizationId, 'org');
252
+ assert.equal(received.socketId, 'socket');
253
+ } finally {
254
+ if (previous === undefined) delete process.env.organization_id;
255
+ else process.env.organization_id = previous;
256
+ }
257
+ });