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,59 @@
1
+ {
2
+ "name": "messages",
3
+ "displayName": "Messages",
4
+ "version": "1.9.0",
5
+ "tier": "free",
6
+ "description": "Internal user-to-user and group messaging with real-time delivery - conversations you can archive, mark unread and search, video calls for up to 6 from any conversation or by picking people, drafts kept per conversation, attachments, and an unread badge in the sidebar.",
7
+ "author": "Darryl Waterhouse",
8
+ "date": "2026-10-21",
9
+ "icon": "message-circle",
10
+ "admin": {
11
+ "sidebar": [
12
+ {
13
+ "id": "messages",
14
+ "text": "Messages",
15
+ "icon": "message-circle",
16
+ "url": "#/plugins/messages",
17
+ "section": "#/plugins/messages",
18
+ "countUrl": "/api/plugins/messages/count",
19
+ "permission": "messages"
20
+ }
21
+ ],
22
+ "routes": [
23
+ {
24
+ "path": "/plugins/messages",
25
+ "view": "plugin-messages",
26
+ "title": "Messages - Domma CMS"
27
+ }
28
+ ],
29
+ "views": {
30
+ "plugin-messages": {
31
+ "entry": "messages/admin/views/messages.js",
32
+ "exportName": "messagesView"
33
+ }
34
+ },
35
+ "css": [
36
+ "admin/css/messages.css"
37
+ ]
38
+ },
39
+ "permissions": [
40
+ {
41
+ "key": "messages",
42
+ "label": "Messages",
43
+ "group": "Plugins",
44
+ "description": "Send and read your own messages (read). Admins also run managed groups.",
45
+ "actions": [
46
+ {
47
+ "key": "read",
48
+ "label": "Use",
49
+ "description": "Send, read and manage your own conversations"
50
+ }
51
+ ],
52
+ "grant": [
53
+ "admin",
54
+ "user"
55
+ ]
56
+ }
57
+ ],
58
+ "minCmsVersion": "0.109.5"
59
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Messages - the call page (1.4): `/messages-call/<conversation>?u=&x=&s=`.
3
+ *
4
+ * A public page, since it is served outside the admin, but it only ever holds
5
+ * a call for someone carrying a link made for them (POST
6
+ * /api/plugins/messages/threads/:id/call) who is still in the conversation.
7
+ * The link is checked here only to choose between the call and a plain "not
8
+ * valid" page; the join checks it again (plugin.js, the engine's resolve).
9
+ */
10
+ import path from 'node:path';
11
+ import {fileURLToPath} from 'node:url';
12
+ import {renderBlogPage} from '../../server/services/renderer.js';
13
+ import {getEntry} from '../../server/services/collections.js';
14
+ import {getUserById} from '../../server/services/users.js';
15
+ import {CALL_API, CALL_PATH, checkInvite, checkJoin, credOf, escHtml, loadSecret, whoIsCalling} from './lib/call.js';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ export const ASSET_V = '1.7.0';
19
+
20
+ /** On a bare call address: return to the link this tab kept (the call screen moves it out of the address bar). */
21
+ const RECOVER_CALL = '<script>(function(){try{if(location.search)return;var s=sessionStorage.getItem("dm-call:"+location.pathname);if(s&&s.charAt(0)==="?")location.replace(location.pathname+s);}catch(e){}})();</script>';
22
+
23
+ export default async function messagesPublic(fastify, options = {}) {
24
+ const dataDir = options.dataDir || path.join(HERE, 'data');
25
+ let secret = null;
26
+ const callSecret = () => (secret ??= loadSecret(dataDir));
27
+
28
+ fastify.get(`${CALL_PATH}/:room`, async (request, reply) => {
29
+ const room = String(request.params.room || '');
30
+ const cred = credOf(request.query);
31
+ // A member's link, or (1.7) an invitation to the running call - the join checks the invitation is still live.
32
+ const invited = cred.i === '1';
33
+ const userId = invited ? checkInvite(callSecret(), room, cred) : checkJoin(callSecret(), room, cred);
34
+ const entry = userId ? await getEntry('user-message-threads', room).catch(() => null) : null;
35
+ const user = entry ? await getUserById(userId).catch(() => null) : null;
36
+ const names = {};
37
+ if (entry?.data?.kind === 'dm') {
38
+ for (const id of entry.data.participants ?? []) names[id] = String((await getUserById(id).catch(() => null))?.name || '').trim();
39
+ }
40
+ const who = entry ? whoIsCalling({id: entry.id, ...entry.data}, user, userId, names, {invited}) : null;
41
+ const cfg = who ? {api: CALL_API, room, cred, title: who.title, askName: false, recordingAllowed: false} : null;
42
+ reply.header('X-Robots-Tag', 'noindex, nofollow').header('Cache-Control', 'no-store').header('Referrer-Policy', 'no-referrer');
43
+ const html = await renderBlogPage(path.join(HERE, 'templates', 'call.html'), {
44
+ notValid: who ? '' : '<h1>This call link is not valid</h1><p>Call links last two hours and are only for the person they were made for. Open the conversation in Messages and click Call or Join for a new one.</p>'
45
+ // A reload of the bare address (the call screen took the link out of the address bar): back to it.
46
+ + (Object.keys(request.query || {}).length ? '' : RECOVER_CALL),
47
+ cfg: cfg ? escHtml(JSON.stringify(cfg)) : '{}',
48
+ assetV: ASSET_V
49
+ }, {title: who ? who.title : 'Call', urlPath: CALL_PATH, noindex: true});
50
+ return reply.type('text/html; charset=utf-8').send(html);
51
+ });
52
+ }
@@ -0,0 +1,6 @@
1
+ <main class="container" style="padding: 2rem 0;">
2
+ {{notValid}}
3
+ <link rel="stylesheet" href="/plugins/_lib/public/call/call.css?v={{assetV}}">
4
+ <div data-dm-call='{{cfg}}'><noscript><p>The call needs JavaScript switched on.</p></noscript></div>
5
+ <script type="module" src="/plugins/_lib/public/call/call.js?v={{assetV}}"></script>
6
+ </main>
@@ -0,0 +1,22 @@
1
+ /** Messages 1.8 - what stays in the compose box after a send that took a while (afterSend). */
2
+ import {test} from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import {afterSend} from '../admin/lib/messages.js';
5
+
6
+ const a1 = {mediaId: 'a.png'}; const a2 = {mediaId: 'b.pdf'};
7
+
8
+ test('nothing typed meanwhile: the box empties', () => {
9
+ assert.deepEqual(afterSend({body: 'Hi "there"', attachments: [a1]}, {body: 'Hi "there"', attachments: [a1]}), {body: '', attachments: []});
10
+ });
11
+ test('typed on after the sent text: only the new part stays', () => {
12
+ assert.deepEqual(afterSend({body: 'Hello', attachments: []}, {body: 'Hello\n and more', attachments: []}), {body: 'and more', attachments: []});
13
+ });
14
+ test('edited some other way: everything stays, nothing typed is lost', () => {
15
+ assert.deepEqual(afterSend({body: 'Hello', attachments: []}, {body: 'Help', attachments: []}), {body: 'Help', attachments: []});
16
+ });
17
+ test('an attachment added meanwhile stays; the sent ones go', () => {
18
+ assert.deepEqual(afterSend({body: '', attachments: [a1]}, {body: '', attachments: [a1, a2]}), {body: '', attachments: [a2]});
19
+ });
20
+ test('an empty sent body never swallows what is there', () => {
21
+ assert.deepEqual(afterSend({body: '', attachments: [a1]}, {body: 'caption', attachments: [a1]}), {body: 'caption', attachments: []});
22
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * The Messages API against the real CMS collection service. `make test` runs
3
+ * inside the domma-cms checkout, whose user-messages / user-message-threads
4
+ * data files are tracked - both are snapshotted and restored byte for byte.
5
+ */
6
+ import {test, before, after} from 'node:test';
7
+ import assert from 'node:assert/strict';
8
+ import fs from 'node:fs/promises';
9
+ import path from 'node:path';
10
+ import Fastify from 'fastify';
11
+ import messagesPlugin from '../plugin.js';
12
+
13
+ const FILES = ['user-messages', 'user-message-threads'].map(s => path.resolve('content', 'collections', s, 'data.json'));
14
+ const saved = {};
15
+ let app;
16
+ // Real accounts: since 1.2 a conversation takes only real, active people the sender may reach.
17
+ const ID = {};
18
+ const made = [];
19
+
20
+ before(async () => {
21
+ for (const f of FILES) saved[f] = await fs.readFile(f, 'utf8').catch(() => null);
22
+ const {createUser} = await import('../../../server/services/users.js');
23
+ await (await import('../../../server/services/roles.js')).load();
24
+ const tag = Math.random().toString(36).slice(2, 7);
25
+ for (const k of ['a', 'b', 'c']) {
26
+ const u = await createUser({name: `Member ${k} ${tag}`, email: `dt-${k}-${tag}@example.test`, password: 'password123', role: 'user'});
27
+ ID[k] = u.id; made.push(u.id);
28
+ }
29
+ app = Fastify();
30
+ // The caller is whoever the x-test-user header names; x-no-messages = a role without the permission.
31
+ const authenticate = async (req) => { req.user = {id: req.headers['x-test-user'] || ID.a, role: 'user'}; };
32
+ const requirePermission = () => async (req, reply) => {
33
+ if (req.headers['x-no-messages']) return reply.code(403).send({error: 'Insufficient permissions'});
34
+ };
35
+ await app.register(messagesPlugin, {auth: {authenticate, requireAdmin: async () => {}, requirePermission}, settings: {}});
36
+ });
37
+
38
+ after(async () => {
39
+ await app?.close();
40
+ const {deleteUser} = await import('../../../server/services/users.js');
41
+ for (const id of made) await deleteUser(id).catch(() => {});
42
+ for (const f of FILES) {
43
+ if (saved[f] === null) await fs.rm(f, {force: true});
44
+ else await fs.writeFile(f, saved[f]);
45
+ }
46
+ });
47
+
48
+ const as = (user) => async (method, url, body) => {
49
+ const res = await app.inject({method, url, payload: body, headers: {'x-test-user': user}});
50
+ return {status: res.statusCode, body: res.body ? JSON.parse(res.body) : null};
51
+ };
52
+ const A = (...a) => as(ID.a)(...a);
53
+ const B = (...a) => as(ID.b)(...a);
54
+ let threadId;
55
+
56
+ test('a DM, messages both ways, and the unread badge follows', async () => {
57
+ const t = await A('POST', '/threads', {kind: 'dm', participants: [ID.b]});
58
+ assert.equal(t.status, 201);
59
+ threadId = t.body.id;
60
+ for (const body of ['one', 'two', 'three']) assert.equal((await A('POST', `/threads/${threadId}/messages`, {body})).status, 201);
61
+ const badge = await B('GET', '/count');
62
+ assert.equal(badge.body.count, 3);
63
+ assert.equal((await A('GET', '/count')).body.count, 0, 'your own messages are never unread');
64
+ });
65
+
66
+ test('mark read clears EVERY unread message (the old parallel writes kept all but one)', async () => {
67
+ await B('POST', `/threads/${threadId}/read`);
68
+ assert.equal((await B('GET', '/count')).body.count, 0);
69
+ const list = (await B('GET', '/threads')).body;
70
+ assert.equal(list.find(t => t.id === threadId).unread, 0);
71
+ });
72
+
73
+ test('mark unread brings the newest back; archive is per person and a new message undoes it', async () => {
74
+ assert.equal((await B('POST', `/threads/${threadId}/unread`)).status, 200);
75
+ assert.equal((await B('GET', '/count')).body.count, 1);
76
+ await B('POST', `/threads/${threadId}/archive`, {archived: true});
77
+ assert.equal((await B('GET', '/count')).body.count, 0, 'archived conversations are left out of the badge');
78
+ const aSees = (await A('GET', '/threads')).body.find(t => t.id === threadId);
79
+ assert.ok(!(aSees.archivedBy || []).includes(ID.a), 'the other person is unaffected');
80
+ await A('POST', `/threads/${threadId}/messages`, {body: 'are you there?'});
81
+ const bSees = (await B('GET', '/threads')).body.find(t => t.id === threadId);
82
+ assert.deepEqual(bSees.archivedBy, [], 'a new message brings it back to the inbox');
83
+ assert.equal((await B('GET', '/count')).body.count, 2);
84
+ });
85
+
86
+ test('attachments keep only a media name - a javascript: address never reaches another screen', async () => {
87
+ const r = await A('POST', `/threads/${threadId}/messages`, {body: '', attachments: [
88
+ {mediaId: 'plan.pdf', filename: 'Plan', mime: 'application/pdf', size: 10, url: 'javascript:alert(1)'},
89
+ {mediaId: '../../secret', filename: 'x'}]});
90
+ assert.equal(r.status, 201);
91
+ assert.deepEqual(r.body.attachments, [{mediaId: 'plan.pdf', filename: 'Plan', mime: 'application/pdf', size: 10, url: '/media/plan.pdf'}]);
92
+ const t = (await A('GET', '/threads')).body.find(x => x.id === threadId);
93
+ assert.equal(t.lastMessagePreview, 'Attachment: Plan');
94
+ assert.equal((await A('POST', `/threads/${threadId}/messages`, {body: '', attachments: [{mediaId: '../x'}]})).status, 400);
95
+ });
96
+
97
+ test('/me answers the rights the screen shows; a stranger cannot read or mark a thread', async () => {
98
+ const m = await A('GET', '/me');
99
+ assert.equal(m.body.id, ID.a);
100
+ assert.equal(typeof m.body.isAdmin, 'boolean');
101
+ assert.equal(m.body.editWindowMinutes, 15);
102
+ const C = (...a) => as(ID.c)(...a);
103
+ assert.equal((await C('GET', `/threads/${threadId}/messages`)).status, 404);
104
+ assert.equal((await C('POST', `/threads/${threadId}/unread`)).status, 404);
105
+ assert.equal((await C('POST', `/threads/${threadId}/archive`, {archived: true})).status, 404);
106
+ });
107
+
108
+ test('leaving: the last one out purges the thread and its messages', async () => {
109
+ await A('DELETE', `/threads/${threadId}`);
110
+ await B('DELETE', `/threads/${threadId}`);
111
+ const raw = JSON.parse(await fs.readFile(FILES[0], 'utf8'));
112
+ const entries = Array.isArray(raw) ? raw : (raw.entries || []);
113
+ assert.equal(entries.filter(e => e.data?.threadId === threadId).length, 0);
114
+ });
115
+
116
+ test('/people: names only, never emails; a member sees staff, their own role and their contacts - not everyone', async () => {
117
+ const {createUser, deleteUser} = await import('../../../server/services/users.js');
118
+ // Staff are known by role level, as in production - where the roles are always loaded at boot.
119
+ await (await import('../../../server/services/roles.js')).load();
120
+ const tag = Math.random().toString(36).slice(2, 7);
121
+ const staff = await createUser({name: `Staff ${tag}`, email: `staff-${tag}@example.test`, password: 'password123', role: 'admin'});
122
+ const stranger = await createUser({name: `Stranger ${tag}`, email: `stranger-${tag}@example.test`, password: 'password123', role: 'zz-other'});
123
+ const nameless = await createUser({name: '', email: `nameless-${tag}@example.test`, password: 'password123', role: 'admin'});
124
+ try {
125
+ const list = (await A('GET', '/people')).body;
126
+ const ids = new Set(list.map(p => p.id));
127
+ assert.ok(ids.has(staff.id), 'staff are listed');
128
+ assert.ok(!ids.has(stranger.id), 'someone sharing no role, and no conversation, is not');
129
+ assert.ok(!JSON.stringify(list).includes('@example.test'), 'no email address, even as a fallback name');
130
+ assert.equal(list.find(p => p.id === nameless.id)?.name, 'Unnamed member');
131
+ } finally {
132
+ for (const u of [staff, stranger, nameless]) await deleteUser(u.id).catch(() => {});
133
+ }
134
+ });
135
+
136
+ test('1.2: only real people you may reach; the permission is needed; retention is the starter\'s, a day or more', async () => {
137
+ // Made-up or unreachable people are refused.
138
+ assert.equal((await A('POST', '/threads', {kind: 'dm', participants: ['no-such-user']})).status, 403);
139
+ assert.equal((await A('POST', '/threads', {kind: 'dm', participants: [{$ne: 1}]})).status, 400);
140
+ const {createUser, deleteUser} = await import('../../../server/services/users.js');
141
+ const stranger = await createUser({name: 'Stranger', email: `stranger-${Date.now()}@example.test`, password: 'password123', role: 'zz-other'});
142
+ try {
143
+ assert.equal((await A('POST', '/threads', {kind: 'dm', participants: [stranger.id]})).status, 403, 'shares no role, no conversation');
144
+ } finally {
145
+ await deleteUser(stranger.id).catch(() => {});
146
+ }
147
+
148
+ // Without the Messages permission, nothing.
149
+ const res = await app.inject({method: 'GET', url: '/threads', headers: {'x-test-user': ID.a, 'x-no-messages': '1'}});
150
+ assert.equal(res.statusCode, 403);
151
+
152
+ // A group of three: only its starter changes retention, never below a day.
153
+ const g = await A('POST', '/threads', {kind: 'group-adhoc', name: 'Trio', participants: [ID.b, ID.c]});
154
+ assert.equal(g.status, 201);
155
+ for (const body of ['keep me', 'and me']) await B('POST', `/threads/${g.body.id}/messages`, {body});
156
+ assert.equal((await B('PUT', `/threads/${g.body.id}`, {retentionDays: 1})).status, 403, 'not the starter');
157
+ assert.equal((await A('PUT', `/threads/${g.body.id}`, {retentionDays: -1})).status, 400, 'the wipe-everything value');
158
+ assert.equal((await A('PUT', `/threads/${g.body.id}`, {retentionDays: 0})).status, 400);
159
+ assert.equal((await A('PUT', `/threads/${g.body.id}`, {retentionDays: 30})).status, 200);
160
+ assert.equal((await A('GET', `/threads/${g.body.id}/messages`)).body.messages?.length ?? (await A('GET', `/threads/${g.body.id}/messages`)).body.length, 2, 'nothing was wiped');
161
+
162
+ // In a group, its starter may remove a message; in a DM, the other side may not.
163
+ const mine = await B('POST', `/threads/${g.body.id}/messages`, {body: 'remove me'});
164
+ assert.equal((await A('DELETE', `/threads/${g.body.id}/messages/${mine.body.id}`)).status, 200);
165
+ const dm = await A('POST', '/threads', {kind: 'dm', participants: [ID.c]});
166
+ const said = await (async (...a) => as(ID.c)(...a))('POST', `/threads/${dm.body.id}/messages`, {body: 'mine'});
167
+ assert.equal((await A('DELETE', `/threads/${dm.body.id}/messages/${said.body.id}`)).status, 403, 'starting a DM is not moderation');
168
+ });
169
+
170
+ test('1.7.2: a message is capped at 10000 characters, a name at 80; participants carry no email', async () => {
171
+ const t = await A('POST', '/threads', {kind: 'group-adhoc', name: 'n'.repeat(200), participants: [ID.b]});
172
+ assert.equal(t.status, 201, JSON.stringify(t.body));
173
+ const sent = await A('POST', `/threads/${t.body.id}/messages`, {body: 'x'.repeat(20001)});
174
+ assert.equal(sent.status, 201);
175
+ assert.equal(sent.body.body.length, 10000);
176
+ const edited = await A('PUT', `/threads/${t.body.id}/messages/${sent.body.id}`, {body: 'y'.repeat(20001)});
177
+ assert.equal(edited.status, 200, JSON.stringify(edited.body));
178
+ assert.equal(edited.body.body.length, 10000);
179
+ assert.equal((await A('PUT', `/threads/${t.body.id}`, {name: 'm'.repeat(200)})).body.name.length, 80);
180
+ const mine = (await B('GET', '/threads')).body;
181
+ for (const th of mine) for (const p of th.participants) {
182
+ assert.equal(p.email, undefined, 'no email in a conversation\'s people');
183
+ assert.ok(p.name, 'a name still');
184
+ }
185
+ await A('POST', `/threads/${t.body.id}/leave`);
186
+ await B('POST', `/threads/${t.body.id}/leave`);
187
+ });