domma-cms 1.8.32 → 1.8.34

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.
Files changed (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -0,0 +1,497 @@
1
+ /**
2
+ * Calls in a conversation (1.4): the join links, who may join, and the
3
+ * "started / ended" lines - against the real CMS collection service and the
4
+ * real call engine (_lib/call). Snapshots and restores the tracked
5
+ * user-messages / user-message-threads data files, as api.test.js does.
6
+ */
7
+ import {test, before, after} from 'node:test';
8
+ import assert from 'node:assert/strict';
9
+ import fs from 'node:fs/promises';
10
+ import http from 'node:http';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import Fastify from 'fastify';
14
+ import messagesPlugin from '../plugin.js';
15
+ import {checkInvite, checkJoin, inviteLink, joinLink, LINK_TTL_MS, loadSecret, RING_MS, signJoin, whoIsCalling} from '../lib/call.js';
16
+ import {registerDirectMessenger, unregisterDirectMessenger} from '../../../server/services/hooks.js';
17
+ import {buildBubbles, callButton, callGroupName, callTarget, ringingCalls} from '../admin/lib/messages.js';
18
+
19
+ const FILES = ['user-messages', 'user-message-threads'].map(s => path.resolve('content', 'collections', s, 'data.json'));
20
+ const saved = {};
21
+ const ID = {};
22
+ const made = [];
23
+ const notices = [];
24
+ const resolved = [];
25
+ let app, dataDir, calls, base;
26
+
27
+ before(async () => {
28
+ for (const f of FILES) saved[f] = await fs.readFile(f, 'utf8').catch(() => null);
29
+ dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'msg-call-'));
30
+ const {createUser} = await import('../../../server/services/users.js');
31
+ await (await import('../../../server/services/roles.js')).load();
32
+ const tag = Math.random().toString(36).slice(2, 7);
33
+ for (const k of ['a', 'b', 'c']) {
34
+ const u = await createUser({name: `Caller ${k} ${tag}`, email: `ct-${k}-${tag}@example.test`, password: 'password123', role: 'user'});
35
+ ID[k] = u.id; made.push(u.id);
36
+ }
37
+ app = Fastify();
38
+ const authenticate = async (req) => { req.user = {id: req.headers['x-test-user'] || ID.a, role: 'user'}; };
39
+ const hooks = {
40
+ notify: (p) => { notices.push(p); },
41
+ resolveNotification: (key, source) => { resolved.push([key, source]); },
42
+ registerNotificationSource: () => {},
43
+ // 1.7: Messages is core's direct messenger (a call's "Add people" in any Tool).
44
+ registerDirectMessenger: (fn) => registerDirectMessenger('messages', fn)
45
+ };
46
+ await app.register(messagesPlugin, {auth: {authenticate, requireAdmin: async () => {}}, settings: {}, hooks, dataDir,
47
+ callSweepMs: 1e9, onCalls: (c) => { calls = c; }});
48
+ await app.listen({port: 0, host: '127.0.0.1'});
49
+ base = `http://127.0.0.1:${app.server.address().port}`;
50
+ });
51
+
52
+ after(async () => {
53
+ unregisterDirectMessenger('messages');
54
+ await app?.close();
55
+ const {deleteUser} = await import('../../../server/services/users.js');
56
+ for (const id of made) await deleteUser(id).catch(() => {});
57
+ for (const f of FILES) {
58
+ if (saved[f] === null) await fs.rm(f, {force: true});
59
+ else await fs.writeFile(f, saved[f]);
60
+ }
61
+ await fs.rm(dataDir, {recursive: true, force: true});
62
+ });
63
+
64
+ const as = (user) => async (method, url, body, headers = {}) => {
65
+ const res = await app.inject({method, url, payload: body, headers: {'x-test-user': user, ...headers}});
66
+ return {status: res.statusCode, body: res.body ? JSON.parse(res.body) : null, headers: res.headers};
67
+ };
68
+ const A = (...a) => as(ID.a)(...a);
69
+ const B = (...a) => as(ID.b)(...a);
70
+ const C = (...a) => as(ID.c)(...a);
71
+ const credFrom = (url) => Object.fromEntries(new URL(url, 'http://x').searchParams);
72
+ const roomOf = (url) => decodeURIComponent(new URL(url, 'http://x').pathname.split('/').pop());
73
+ const callLines = async (threadId) => (await A('GET', `/threads/${threadId}/messages`)).body.filter(m => m.kind === 'call');
74
+
75
+ /** Listen on a user's event stream; returns the events seen so far and a stop(). */
76
+ function listen(userId) {
77
+ const events = [];
78
+ const req = http.get(`${base}/stream`, {headers: {'x-test-user': userId}});
79
+ let buf = '';
80
+ req.on('response', (res) => res.on('data', (chunk) => {
81
+ buf += chunk;
82
+ let at;
83
+ while ((at = buf.indexOf('\n\n')) >= 0) {
84
+ const block = buf.slice(0, at);
85
+ buf = buf.slice(at + 2);
86
+ const ev = /^event: (.+)$/m.exec(block)?.[1];
87
+ const data = /^data: (.+)$/m.exec(block)?.[1];
88
+ if (ev) events.push({event: ev, data: data ? JSON.parse(data) : null});
89
+ }
90
+ }));
91
+ req.on('error', () => {});
92
+ return {events, stop: () => req.destroy()};
93
+ }
94
+ const settle = (ms = 150) => new Promise(r => setTimeout(r, ms));
95
+
96
+ // ---- The link and the rule (pure) ------------------------------------------------
97
+
98
+ test('the secret is made once and kept', async () => {
99
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'msg-secret-'));
100
+ const s1 = loadSecret(dir);
101
+ assert.match(s1, /^[0-9a-f]{64}$/);
102
+ assert.equal(loadSecret(dir), s1, 'read back, not remade');
103
+ assert.equal((await fs.stat(path.join(dir, 'call-secret'))).mode & 0o777, 0o600);
104
+ await fs.rm(dir, {recursive: true, force: true});
105
+ });
106
+
107
+ test('a link names its thread, its person and its expiry; anything altered, stale or far-dated is refused', () => {
108
+ const secret = 'a'.repeat(64);
109
+ const now = Date.parse('2026-09-30T10:00:00Z');
110
+ const url = joinLink(secret, 'thread-1', 'user-1', now);
111
+ assert.match(url, /^\/messages-call\/thread-1\?u=user-1&x=\d+&s=[\w-]+$/);
112
+ const cred = credFrom(url);
113
+ assert.equal(Number(cred.x), now + LINK_TTL_MS);
114
+ assert.equal(checkJoin(secret, 'thread-1', cred, now), 'user-1');
115
+ assert.equal(checkJoin(secret, 'thread-1', cred, now + LINK_TTL_MS - 1), 'user-1');
116
+ assert.equal(checkJoin(secret, 'thread-1', cred, now + LINK_TTL_MS), null, 'expired');
117
+ assert.equal(checkJoin(secret, 'thread-2', cred, now), null, 'another conversation');
118
+ assert.equal(checkJoin(secret, 'thread-1', {...cred, u: 'user-2'}, now), null, 'another person');
119
+ assert.equal(checkJoin(secret, 'thread-1', {...cred, x: String(Number(cred.x) + 1)}, now), null, 'expiry moved');
120
+ assert.equal(checkJoin('b'.repeat(64), 'thread-1', cred, now), null, 'another site');
121
+ assert.equal(checkJoin(secret, 'thread-1', {...cred, s: cred.s.slice(1)}, now), null, 'signature cut');
122
+ const far = now + LINK_TTL_MS * 10;
123
+ assert.equal(checkJoin(secret, 'thread-1', {u: 'user-1', x: String(far), s: signJoin(secret, 'thread-1', 'user-1', far)}, now), null,
124
+ 'a link dated further ahead than one is ever made (a leaked secret cannot mint forever-links unnoticed)');
125
+ assert.equal(checkJoin(secret, '../x', cred, now), null);
126
+ assert.equal(checkJoin(secret, 'thread-1', {}, now), null);
127
+ });
128
+
129
+ test('1.7: an invitation link is its own kind - signed for invite, not usable as a member link or the other way round', () => {
130
+ const secret = 'f'.repeat(64);
131
+ const now = Date.now();
132
+ const cred = Object.fromEntries(new URL(inviteLink(secret, 't1', 'u3', now), 'http://x').searchParams);
133
+ assert.equal(cred.i, '1');
134
+ assert.equal(checkInvite(secret, 't1', cred, now), 'u3');
135
+ assert.equal(checkJoin(secret, 't1', cred, now), null, 'not a member link');
136
+ const {i, ...plain} = cred;
137
+ assert.equal(checkJoin(secret, 't1', plain, now), null, 'dropping i= does not make it a member link');
138
+ const member = Object.fromEntries(new URL(joinLink(secret, 't1', 'u1', now), 'http://x').searchParams);
139
+ assert.equal(checkInvite(secret, 't1', {...member, i: '1'}, now), null, 'a member link is not an invitation');
140
+ assert.equal(checkInvite(secret, 't2', cred, now), null, 'another conversation');
141
+ });
142
+
143
+ test('whoIsCalling: members only, still active; straight in (no host, no waiting room), six at most, no recording', () => {
144
+ const t = {id: 't1', kind: 'group-adhoc', name: 'Team', participants: ['u1', 'u2']};
145
+ const who = whoIsCalling(t, {id: 'u1', name: 'Sam Patel'}, 'u1');
146
+ assert.deepEqual(who, {mayInvite: true, inviteExternal: false, title: 'Team', host: false, autoAdmit: true, name: 'Sam Patel', max: 6,
147
+ recordingAllowed: false, identity: 't1:u1'});
148
+ assert.equal(whoIsCalling(t, {id: 'u3', name: 'X'}, 'u3'), null, 'not in the conversation');
149
+ // 1.7: someone invited to the running call - let in, but may not add others.
150
+ const guest = whoIsCalling(t, {id: 'u3', name: 'Ola'}, 'u3', {}, {invited: true});
151
+ assert.equal(guest.autoAdmit, true);
152
+ assert.equal(guest.mayInvite, false);
153
+ assert.equal(guest.identity, 't1:u3');
154
+ assert.equal(whoIsCalling(t, {id: 'u1', name: 'X', disabled: true}, 'u1'), null, 'account switched off');
155
+ assert.equal(whoIsCalling(t, null, 'u1'), null, 'account gone');
156
+ assert.equal(whoIsCalling(null, {id: 'u1'}, 'u1'), null);
157
+ const dm = whoIsCalling({id: 'd', kind: 'dm', participants: ['u1', 'u2']}, {id: 'u1', name: 'Ann'}, 'u1', {u1: 'Ann', u2: 'Bob'});
158
+ assert.equal(dm.title, 'Call: Ann and Bob');
159
+ });
160
+
161
+ test('the screen: call lines, the Call button and the "is calling" notices', () => {
162
+ const msgs = [
163
+ {id: 'm1', kind: 'call', body: 'Sam started a call', senderId: 'u2', createdAt: '2026-09-30T10:00:00Z', call: {event: 'started'}},
164
+ {id: 'm2', body: 'hi', senderId: 'u2', createdAt: '2026-09-30T10:01:00Z'}
165
+ ];
166
+ const b = buildBubbles(msgs, {myId: 'u1', isGroup: true, nameOf: () => 'Sam', now: Date.parse('2026-09-30T12:00:00Z'), liveCallMessageId: 'm1'});
167
+ const line = b.find(x => x.id === 'm1');
168
+ assert.equal(line.isCall, true);
169
+ assert.equal(line.canJoin, true);
170
+ assert.match(line.callText, /^Sam started a call · \d\d:\d\d$/);
171
+ assert.equal(b.find(x => x.id === 'm2').showSender, true, 'the next message still says who sent it');
172
+ assert.equal(buildBubbles(msgs, {myId: 'u1'}).find(x => x.id === 'm1').canJoin, false, 'no Join once the call is over');
173
+
174
+ const people = (n) => Array.from({length: n}, (_, i) => ({id: `u${i}`}));
175
+ assert.equal(callButton({participants: people(2)}).label, 'Call');
176
+ assert.equal(callButton({participants: people(2), call: {byName: 'Sam', in: 2}}).label, 'Join call (2)');
177
+ assert.match(callButton({participants: people(7)}).title, /holds 6 people/);
178
+ assert.doesNotMatch(callButton({participants: people(6)}).title, /holds 6/);
179
+
180
+ const threads = [
181
+ {id: 't1', kind: 'dm', call: {by: 'u2', byName: 'Sam', startedAt: 'S1', in: 1}},
182
+ {id: 't2', kind: 'group-adhoc', name: 'Team', call: {by: 'u1', byName: 'Me', startedAt: 'S2'}},
183
+ {id: 't3', kind: 'group-adhoc', name: 'Old', archivedBy: ['u1'], call: {by: 'u2', startedAt: 'S3'}},
184
+ {id: 't4', kind: 'group-adhoc', name: 'Ops', call: null}
185
+ ];
186
+ const r = ringingCalls(threads, 'u1');
187
+ assert.deepEqual(r.map(x => x.id), ['t1'], 'not my own call, not archived, not without a call');
188
+ assert.equal(r[0].text, 'Sam is calling');
189
+ assert.equal(r[0].count, '1 person in');
190
+ assert.deepEqual(ringingCalls(threads, 'u1', ['t1|S1']), [], 'put aside');
191
+ });
192
+
193
+ // ---- Through the routes and the engine -------------------------------------------
194
+
195
+ let threadId, urlA;
196
+
197
+ test('Call posts one "started" line (not two while it runs), tells everyone live and raises a notice for the others', async () => {
198
+ const g = await A('POST', '/threads', {kind: 'group-adhoc', name: 'Standup', participants: [ID.b]});
199
+ assert.equal(g.status, 201);
200
+ threadId = g.body.id;
201
+ const streamB = listen(ID.b);
202
+ await settle();
203
+
204
+ const r = await A('POST', `/threads/${threadId}/call`);
205
+ assert.equal(r.status, 200);
206
+ urlA = r.body.url;
207
+ assert.equal(roomOf(urlA), threadId);
208
+ assert.equal(credFrom(urlA).u, ID.a);
209
+ assert.equal(r.body.call.by, ID.a);
210
+
211
+ const again = await A('POST', `/threads/${threadId}/call`);
212
+ const joinB = await B('POST', `/threads/${threadId}/call`);
213
+ assert.equal(again.status, 200);
214
+ assert.equal(credFrom(joinB.body.url).u, ID.b, "each person gets a link of their own");
215
+ assert.equal(joinB.body.call.by, ID.a, 'B joins the call A started');
216
+
217
+ const lines = await callLines(threadId);
218
+ assert.equal(lines.length, 1, 'one line, however many times Call is pressed');
219
+ assert.match(lines[0].body, /^Caller a .+ started a call$/);
220
+ assert.deepEqual([...lines[0].readBy].sort(), [ID.a, ID.b].sort(), 'a call line never counts as unread');
221
+ assert.equal((await B('GET', '/count')).body.count, 0);
222
+ assert.equal((await A('PUT', `/threads/${threadId}/messages/${lines[0].id}`, {body: 'x'})).status, 403, 'a call line cannot be edited');
223
+
224
+ await settle();
225
+ streamB.stop();
226
+ const started = streamB.events.filter(e => e.event === 'call:started');
227
+ assert.equal(started.length, 1);
228
+ assert.equal(started[0].data.threadId, threadId);
229
+ assert.equal(started[0].data.call.by, ID.a);
230
+ assert.ok(streamB.events.some(e => e.event === 'message:new' && e.data.message.kind === 'call'), 'the line arrives live too');
231
+
232
+ const n = notices.filter(p => p.dedupeKey === `call:${threadId}`);
233
+ assert.equal(n.length, 1);
234
+ assert.deepEqual(n[0].audience, {userIds: [ID.b]}, 'the others, not the caller');
235
+ assert.equal(n[0].source, 'calls');
236
+
237
+ const list = (await B('GET', '/threads')).body.find(t => t.id === threadId);
238
+ assert.equal(list.call.by, ID.a, 'the conversation list says a call is on');
239
+ });
240
+
241
+ test('the call routes are mounted: a link joins; a forged, stale or outsider link does not', async () => {
242
+ const join = (room, cred) => app.inject({method: 'POST', url: `/call/${encodeURIComponent(room)}/join`, payload: cred});
243
+ const ok = await join(threadId, credFrom(urlA));
244
+ assert.equal(ok.statusCode, 200, ok.body);
245
+ const joined = JSON.parse(ok.body);
246
+ assert.equal(joined.state, 'in', 'straight in - no waiting room');
247
+ assert.equal(joined.host, false, 'nobody is "(host)" - everyone is equal');
248
+ assert.match(joined.name, /^Caller a /);
249
+ assert.equal(joined.max, 6);
250
+ assert.equal(joined.recordingAllowed, false);
251
+
252
+ const secret = (await fs.readFile(path.join(dataDir, 'call-secret'), 'utf8')).trim();
253
+ const forged = {...credFrom(urlA), s: signJoin('f'.repeat(64), threadId, ID.a, credFrom(urlA).x)};
254
+ assert.equal((await join(threadId, forged)).statusCode, 404, 'forged');
255
+ const past = Date.now() - 1000;
256
+ assert.equal((await join(threadId, {u: ID.a, x: String(past), s: signJoin(secret, threadId, ID.a, past)})).statusCode, 404, 'expired');
257
+ // A properly signed link for someone NOT in the conversation (a member who left, say) is still refused.
258
+ assert.equal((await join(threadId, credFrom(joinLink(secret, threadId, ID.c)))).statusCode, 404, 'not a member');
259
+ assert.equal((await join('someone-elses-thread', credFrom(urlA))).statusCode, 404, 'a link for another room');
260
+ assert.equal((await C('POST', `/threads/${threadId}/call`)).status, 404, 'an outsider cannot get a link');
261
+
262
+ // The call page itself is served by plugin.public.js (checked in the browser); here, the engine counts A in.
263
+ assert.equal(calls.rooms.countRoom(threadId).in, 1);
264
+ await calls.sweep();
265
+ assert.equal((await A('GET', '/threads')).body.find(t => t.id === threadId).call.in, 1);
266
+
267
+ // B joins; both leave; the sweep writes "Call ended" and tells everyone.
268
+ const okB = JSON.parse((await join(threadId, credFrom((await B('POST', `/threads/${threadId}/call`)).body.url))).body);
269
+ assert.equal(calls.rooms.countRoom(threadId).in, 2);
270
+ const streamA = listen(ID.a);
271
+ await settle();
272
+ for (const p of [joined, okB]) {
273
+ const res = await app.inject({method: 'POST', url: `/call/${threadId}/leave`, headers: {'x-call-id': p.id, 'x-call-secret': p.secret}});
274
+ assert.equal(res.statusCode, 200);
275
+ }
276
+ await calls.sweep();
277
+ const lines = await callLines(threadId);
278
+ assert.equal(lines.length, 2);
279
+ assert.match(lines[1].body, /^Call ended \((under a minute|\d+ min)\)$/);
280
+ assert.equal(lines[1].call.event, 'ended');
281
+ assert.equal(calls.live.has(threadId), false);
282
+ assert.deepEqual(resolved.find(([k]) => k === `call:${threadId}`), [`call:${threadId}`, 'calls'], 'the notice goes when the call ends');
283
+ await settle();
284
+ streamA.stop();
285
+ assert.ok(streamA.events.some(e => e.event === 'call:ended' && e.data.threadId === threadId));
286
+ assert.equal((await A('GET', '/threads')).body.find(t => t.id === threadId).call, null);
287
+ });
288
+
289
+ test('a call nobody joins is given up after a while; joining later starts it again', async () => {
290
+ const r = await A('POST', `/threads/${threadId}/call`);
291
+ assert.equal(r.status, 200);
292
+ await calls.sweep(Date.now() + 60e3);
293
+ assert.equal(calls.live.has(threadId), true, 'still ringing a minute on');
294
+ await calls.sweep(Date.now() + RING_MS + 1000);
295
+ assert.equal(calls.live.has(threadId), false);
296
+ const lines = await callLines(threadId);
297
+ assert.equal(lines.at(-1).body, 'Call ended - nobody joined');
298
+
299
+ // The two-hour link still works: joining starts a new call (and says so).
300
+ const res = await app.inject({method: 'POST', url: `/call/${threadId}/join`, payload: credFrom(r.body.url)});
301
+ assert.equal(res.statusCode, 200);
302
+ assert.equal(calls.live.has(threadId), true);
303
+ assert.match((await callLines(threadId)).at(-1).body, /started a call$/);
304
+ const p = JSON.parse(res.body);
305
+ await app.inject({method: 'POST', url: `/call/${threadId}/leave`, headers: {'x-call-id': p.id, 'x-call-secret': p.secret}});
306
+ await calls.sweep();
307
+ assert.match((await callLines(threadId)).at(-1).body, /^Call ended/);
308
+ });
309
+
310
+ test('two people pressing Call at once start ONE call', async () => {
311
+ const startedCount = async () => (await callLines(threadId)).filter(m => m.call?.event === 'started').length;
312
+ const before = await startedCount();
313
+ const [ra, rb] = await Promise.all([A('POST', `/threads/${threadId}/call`), B('POST', `/threads/${threadId}/call`)]);
314
+ assert.equal(await startedCount(), before + 1);
315
+ assert.equal(ra.body.call.startedAt, rb.body.call.startedAt);
316
+ await calls.sweep(Date.now() + RING_MS + 1000);
317
+ });
318
+
319
+ test('1.6: chat typed in the call is posted into the conversation once, from its sender; a reopened call shows it', async () => {
320
+ await calls.sweep(Date.now() + RING_MS + 1000);
321
+ const join = async (url) => JSON.parse((await app.inject({method: 'POST', url: `/call/${threadId}/join`, payload: credFrom(url)})).body);
322
+ const say = (p, text) => app.inject({method: 'POST', url: `/call/${threadId}/chat`, headers: {'x-call-id': p.id, 'x-call-secret': p.secret}, payload: {text}});
323
+ const leave = (p) => app.inject({method: 'POST', url: `/call/${threadId}/leave`, headers: {'x-call-id': p.id, 'x-call-secret': p.secret}});
324
+ const callMsgs = async (who = A) => (await who('GET', `/threads/${threadId}/messages?limit=200`)).body.filter(m => m.callChat);
325
+ const before = (await callMsgs()).length;
326
+ const streamB = listen(ID.b);
327
+ try {
328
+ await settle();
329
+
330
+ const a = await join((await A('POST', `/threads/${threadId}/call`)).body.url);
331
+ assert.equal((await say(a, 'Only me here')).statusCode, 200);
332
+ const b = await join((await B('POST', `/threads/${threadId}/call`)).body.url);
333
+ assert.equal((await say(a, 'Hello B')).statusCode, 200);
334
+ assert.equal((await say(b, 'Hi A <3')).statusCode, 200);
335
+
336
+ const got = (await callMsgs()).slice(before);
337
+ assert.deepEqual(got.map(m => [m.senderId, m.body]), [[ID.a, 'Only me here'], [ID.a, 'Hello B'], [ID.b, 'Hi A <3']], 'each line once, from its sender');
338
+ assert.deepEqual(got[0].readBy, [ID.a], 'B was not in the call yet: unread for B');
339
+ assert.deepEqual([...got[1].readBy].sort(), [ID.a, ID.b].sort(), 'everyone in the call has read it');
340
+ assert.ok(got.every(m => m.callChat.id && m.callChat.who && m.kind === undefined), 'ordinary messages, tagged with the call item');
341
+ const all = (await A('GET', `/threads/${threadId}/messages?limit=200`)).body;
342
+ assert.ok(!all.some(m => /^Caller [abc] \w+ (joined|left)$/.test(String(m.body))), 'system lines are not posted');
343
+ await settle();
344
+ assert.ok(streamB.events.some(e => e.event === 'message:new' && e.data.message.body === 'Hello B'), 'told live, like any message');
345
+ assert.ok(streamB.events.some(e => e.event === 'message:new' && e.data.message.body === 'Hi A <3'), 'the sender too (their screen did not send it)');
346
+ streamB.stop();
347
+ assert.equal((await A('GET', '/threads')).body.find(t => t.id === threadId).lastMessagePreview, 'Hi A <3');
348
+
349
+ // Both leave; the call ends. Joining again starts a new call whose chat shows the earlier lines - A's as A's.
350
+ await leave(b); await leave(a);
351
+ await calls.sweep();
352
+ const a2 = await join((await A('POST', `/threads/${threadId}/call`)).body.url);
353
+ const polled = JSON.parse((await app.inject({method: 'GET', url: `/call/${threadId}/poll?since=0`, headers: {'x-call-id': a2.id, 'x-call-secret': a2.secret}})).body);
354
+ const chat = polled.events.find(e => e.type === 'admitted').chat;
355
+ const old = chat.filter(i => i.kind === 'chat').slice(-3);
356
+ assert.deepEqual(old.map(i => [i.text, i.who === a2.who]), [['Only me here', true], ['Hello B', true], ['Hi A <3', false]]);
357
+ assert.deepEqual(old.map(i => i.id), got.map(m => m.callChat.id), 'the same items (the page de-duplicates by id)');
358
+ assert.equal((await callMsgs()).length, before + 3, 'the loaded history is not posted again');
359
+ // A deleted message is not brought back into a call.
360
+ await B('DELETE', `/threads/${threadId}/messages/${got[2].id}`);
361
+ await leave(a2);
362
+ await calls.sweep();
363
+ const a3 = await join((await A('POST', `/threads/${threadId}/call`)).body.url);
364
+ const polled3 = JSON.parse((await app.inject({method: 'GET', url: `/call/${threadId}/poll?since=0`, headers: {'x-call-id': a3.id, 'x-call-secret': a3.secret}})).body);
365
+ assert.ok(!polled3.events.find(e => e.type === 'admitted').chat.some(i => i.text === 'Hi A <3'));
366
+ await leave(a3);
367
+ await calls.sweep();
368
+ assert.equal(calls.present.has(threadId), false, 'nobody left in the call');
369
+ } finally { streamB.stop(); }
370
+ });
371
+
372
+ test('leaving the conversation takes you out of its call; the last one leaving ends it', async () => {
373
+ const urlA2 = (await A('POST', `/threads/${threadId}/call`)).body.url;
374
+ assert.equal(calls.live.has(threadId), true);
375
+ const join = (url) => app.inject({method: 'POST', url: `/call/${threadId}/join`, payload: credFrom(url)});
376
+ assert.equal((await join(urlA2)).statusCode, 200);
377
+ assert.equal((await join((await B('POST', `/threads/${threadId}/call`)).body.url)).statusCode, 200);
378
+ assert.equal(calls.rooms.countRoom(threadId).in, 2);
379
+ await A('DELETE', `/threads/${threadId}`);
380
+ assert.equal(calls.rooms.countRoom(threadId).in, 1, 'A left the conversation, so A is out of its call');
381
+ assert.equal(calls.live.get(threadId).participants.includes(ID.a), false, 'and hears no more of it');
382
+ await B('DELETE', `/threads/${threadId}`);
383
+ assert.equal(calls.live.has(threadId), false);
384
+ });
385
+
386
+ test('Call… (1.5): /me names you; picking one person calls in your DM, several in the group of exactly them, else a new one', async () => {
387
+ const mine = await A('GET', '/me');
388
+ assert.match(mine.body.name, /^Caller a /, '/me carries your name (the default group name starts with it)');
389
+
390
+ // One person: the DM you have - found on the list, and the server dedupes it too.
391
+ const dm = (await A('POST', '/threads', {kind: 'dm', participants: [ID.b]})).body;
392
+ let list = (await A('GET', '/threads')).body;
393
+ assert.equal(callTarget(list, ID.a, [ID.b]).id, dm.id);
394
+ assert.equal((await A('POST', '/threads', {kind: 'dm', participants: [ID.b]})).body.id, dm.id, 'never a second DM');
395
+
396
+ // Several: no group of exactly B and C yet -> make one, named after everyone.
397
+ const t1 = callTarget(list, ID.a, [ID.b, ID.c]);
398
+ assert.deepEqual(t1.create, {kind: 'group-adhoc', participants: [ID.b, ID.c]});
399
+ const people = [{name: mine.body.name}, ...t1.create.participants.map(id => list.flatMap(t => t.participants).find(p => p.id === id) || {name: 'Caller c x'})];
400
+ const group = (await A('POST', '/threads', {...t1.create, name: callGroupName(people)})).body;
401
+ assert.match(group.name, /^Caller, Caller and Caller$/);
402
+ list = (await A('GET', '/threads')).body;
403
+ assert.equal(callTarget(list, ID.a, [ID.c, ID.b]).id, group.id, 'the same people in any order: reused');
404
+ assert.ok(callTarget(list, ID.a, [ID.c]).create, 'a subset is not that group');
405
+
406
+ // Its call starts as the in-conversation button's does.
407
+ const res = await A('POST', `/threads/${group.id}/call`);
408
+ assert.equal(res.status, 200);
409
+ assert.equal(roomOf(res.body.url), group.id);
410
+ assert.equal(res.body.call.by, ID.a);
411
+
412
+ // Archived by you: a new group, not that one.
413
+ await A('POST', `/threads/${group.id}/archive`, {archived: true});
414
+ list = (await A('GET', '/threads')).body;
415
+ assert.ok(callTarget(list, ID.a, [ID.b, ID.c]).create);
416
+ });
417
+
418
+ test('1.7 Add people: someone in the call rings a team member outside the conversation - notice, DM with their own link, they join; nobody else can', async () => {
419
+ const dm = await A('POST', '/threads', {kind: 'dm', participants: [ID.b]});
420
+ const room = dm.body.id;
421
+ await calls.sweep(Date.now() + RING_MS + 1000);
422
+ const urlA = (await A('POST', `/threads/${room}/call`)).body.url;
423
+ const join = async (cred) => app.inject({method: 'POST', url: `/call/${room}/join`, payload: cred});
424
+ const a = JSON.parse((await join(credFrom(urlA))).body);
425
+ assert.equal(a.canInvite, true, 'a member in the call may add people');
426
+ assert.deepEqual(a.inviteOptions, {search: true, typed: false, copyLink: false, external: false}, 'the team only - no contacts, typed emails or copied link');
427
+ const h = {'x-call-id': a.id, 'x-call-secret': a.secret};
428
+ await app.inject({method: 'POST', url: `/call/${room}/chat`, headers: h, payload: {text: 'said before C came'}});
429
+
430
+ const people = JSON.parse((await app.inject({method: 'GET', url: `/call/${room}/people?q=`, headers: h})).body).people;
431
+ assert.ok(people.some(p => p.id === ID.c), 'someone not in the conversation');
432
+ assert.ok(!people.some(p => p.id === ID.b || p.id === ID.a), 'never someone already in it');
433
+ assert.ok(people.every(p => p.kind === 'user'));
434
+
435
+ // Not invited yet: a correctly signed invitation still does not open the call.
436
+ const early = Object.fromEntries(new URL(inviteLink(loadSecret(dataDir), room, ID.c), 'http://x').searchParams);
437
+ assert.equal((await join(early)).statusCode, 404);
438
+ const copy = await app.inject({method: 'POST', url: `/call/${room}/invite`, headers: h, payload: {people: []}});
439
+ assert.equal(copy.statusCode, 409, 'no link to copy in Messages');
440
+
441
+ const r = await app.inject({method: 'POST', url: `/call/${room}/invite`, headers: {...h, host: 'site.test'},
442
+ payload: {people: [{kind: 'user', id: ID.c}, {kind: 'user', id: ID.b}], via: {message: true}}});
443
+ assert.equal(r.statusCode, 200);
444
+ const out = JSON.parse(r.body);
445
+ assert.equal(out.link, '');
446
+ assert.equal(out.results[0].messaged, true, 'C got a direct message');
447
+ assert.ok(out.results[1].error, 'someone already in the conversation is refused');
448
+ const typed = await app.inject({method: 'POST', url: `/call/${room}/invite`, headers: h, payload: {people: [{name: 'X', email: 'x@example.com'}], via: {email: true}}});
449
+ assert.equal(typed.statusCode, 400, 'a typed address is refused (the team only)');
450
+ assert.ok(calls.live.get(room).invited.has(ID.c));
451
+ assert.ok(!(await C('GET', `/threads/${room}/messages`)).body?.length, 'C is NOT added to the conversation');
452
+
453
+ // The DM from A to C carries C's own invitation link.
454
+ const threads = (await C('GET', '/threads')).body;
455
+ const pair = threads.find(t => t.kind === 'dm' && t.participants.some(p => p.id === ID.a));
456
+ const msgs = (await C('GET', `/threads/${pair.id}/messages`)).body;
457
+ const link = msgs.map(m => m.body).find(b => b.includes('/messages-call/')).match(/https?:\/\/\S+/)[0];
458
+ const cred = credFrom(link);
459
+ assert.equal(cred.u, ID.c);
460
+ assert.equal(cred.i, '1');
461
+ const c = JSON.parse((await join(cred)).body);
462
+ assert.equal(c.state, 'in', 'straight in, like the members');
463
+ const cp0 = (await app.inject({method: 'GET', url: `/call/${room}/poll?since=0`, headers: {'x-call-id': c.id, 'x-call-secret': c.secret}})).json();
464
+ const seen = cp0.events.find(e => e.type === 'admitted').chat.map(i => i.text);
465
+ assert.ok(!seen.includes('said before C came'), 'an invitee is not shown the call\'s earlier chat');
466
+ assert.ok(!seen.some(t => /started a call/.test(t)));
467
+ // A member joining now still gets it all.
468
+ const b = JSON.parse((await join(credFrom((await B('POST', `/threads/${room}/call`)).body.url))).body);
469
+ const bp = (await app.inject({method: 'GET', url: `/call/${room}/poll?since=0`, headers: {'x-call-id': b.id, 'x-call-secret': b.secret}})).json();
470
+ assert.ok(bp.events.find(e => e.type === 'admitted').chat.some(i => i.text === 'said before C came'));
471
+ await app.inject({method: 'POST', url: `/call/${room}/leave`, headers: {'x-call-id': b.id, 'x-call-secret': b.secret}});
472
+ assert.equal(c.canInvite, false, 'an invitee cannot add others');
473
+ const cp = await app.inject({method: 'GET', url: `/call/${room}/people?q=`, headers: {'x-call-id': c.id, 'x-call-secret': c.secret}});
474
+ assert.equal(cp.statusCode, 403);
475
+
476
+ // The call ends: the invitation stops working.
477
+ for (const p of [a, c]) await app.inject({method: 'POST', url: `/call/${room}/leave`, headers: {'x-call-id': p.id, 'x-call-secret': p.secret}});
478
+ await calls.sweep();
479
+ assert.equal(calls.live.has(room), false);
480
+ assert.equal((await join(cred)).statusCode, 404, 'not a way into the next call');
481
+ });
482
+
483
+ test('1.7 the direct messenger keeps Messages\' own rule: only someone the sender may reach', async () => {
484
+ const {createUser, deleteUser} = await import('../../../server/services/users.js');
485
+ const tag = Math.random().toString(36).slice(2, 7);
486
+ // No shared role, not staff, never talked: A may not message them.
487
+ const d = await createUser({name: `Dee Outside ${tag}`, email: `dee-${tag}@example.test`, password: 'password123', role: 'jb-candidate'});
488
+ try {
489
+ assert.deepEqual(await calls.sendDirect({fromUserId: ID.a, toUserId: d.id, text: 'Join: https://x'}),
490
+ {sent: false, reason: 'you cannot message them in Messages'});
491
+ const threads = (await A('GET', '/threads')).body;
492
+ assert.ok(!threads.some(t => t.kind === 'dm' && t.participants.some(p => (p.id || p) === d.id)), 'no conversation made');
493
+ assert.deepEqual(await calls.sendDirect({fromUserId: ID.a, toUserId: ID.c, text: 'Join: https://x'}), {sent: true}, 'someone they may reach');
494
+ } finally {
495
+ await deleteUser(d.id).catch(() => {});
496
+ }
497
+ });
@@ -0,0 +1,25 @@
1
+ /** Messages - drafts are one signed-in person's, and a half-made conversation is asked about (admin/lib/messages.js). */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {NEW_DRAFT_QUESTION, RESTORE_ANSWERS, cleanNewDraft, draftKey} from '../admin/lib/messages.js';
5
+
6
+ test('draft keys carry the user id; no user, no key', () => {
7
+ assert.equal(draftKey('messages.drafts', 'u-1'), 'messages.drafts.u-1');
8
+ assert.equal(draftKey('messages.newDraft', 'u-1'), 'messages.newDraft.u-1');
9
+ assert.notEqual(draftKey('messages.drafts', 'a'), draftKey('messages.drafts', 'b'));
10
+ for (const none of [undefined, null, '', ' ', {}, 'a b', 'x'.repeat(200)]) assert.equal(draftKey('messages.drafts', none), '', String(none));
11
+ });
12
+
13
+ test('a kept new conversation is whitelisted; an empty one is nothing', () => {
14
+ assert.deepEqual(cleanNewDraft({kind: 'group-adhoc', name: 'Team', participants: ['a', 3, 'b'], extra: 1}), {kind: 'group-adhoc', name: 'Team', participants: ['a', 'b']});
15
+ assert.deepEqual(cleanNewDraft({kind: 'evil', participants: ['a']}), {kind: 'dm', name: '', participants: ['a']});
16
+ assert.equal(cleanNewDraft({kind: 'dm', name: ' ', participants: []}), null);
17
+ assert.equal(cleanNewDraft(null), null);
18
+ assert.equal(cleanNewDraft([]), null);
19
+ });
20
+
21
+ test('the restore question: OK throws away, Cancel keeps', () => {
22
+ assert.match(NEW_DRAFT_QUESTION, /^Throw away the changes you left unsaved/);
23
+ assert.equal(RESTORE_ANSWERS.confirmText, 'Throw away');
24
+ assert.equal(RESTORE_ANSWERS.cancelText, 'Keep them');
25
+ });