@bongos/core 1.24.52 → 1.24.53

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,71 @@
1
+ // src/link-preview.js — the one link-preview card every public page carries (task 1005091,
2
+ // goal 1000138).
3
+ //
4
+ // When someone pastes a link to the projects hub, the community page, the status page, the
5
+ // API docs or the raise page into a chat, the preview is built from Open Graph and Twitter
6
+ // card tags. Without them a chat shows a bare URL, or the first image it happens to find. So
7
+ // every served page gets the same card: the page's own <title> and description, the
8
+ // instance's product name as the site, and one 1200x630 image drawn from the shared mark
9
+ // (modules/ui-design/public/brand/og-card.png, written by scripts/bongos/gen-brand.js --png
10
+ // in the pack's colours).
11
+ //
12
+ // ONE TEMPLATE, APPLIED WHERE PAGES ARE SERVED: serve-internal.js injectBrandingGlobal (the
13
+ // landing and hub pages, the status page, the API docs), the off-box status mirror (so it
14
+ // still matches the live page), and the raise views through the module doorway. A page that
15
+ // already writes its own card (the apex landing, which has its own og-card.jpg) keeps it:
16
+ // the template steps aside when it finds an og:title.
17
+ //
18
+ // Pure: everything it reads is a parameter. The image URL is absolute when the instance has
19
+ // a public origin (a chat fetches it from elsewhere), root-relative otherwise.
20
+
21
+ 'use strict';
22
+
23
+ const { escapeHtml } = require('./html-escape');
24
+
25
+ const CARD_PATH = '/modules/ui-design/public/brand/og-card.png';
26
+ const CARD_SIZE = { width: 1200, height: 630 };
27
+
28
+ // The page's own title and description, as text. They arrive already escaped (they were
29
+ // written into the page), so the entities are decoded once and re-escaped on the way out.
30
+ const DECODE = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'" };
31
+ const decode = (s) => String(s || '').replace(/&(amp|lt|gt|quot|#39);/g, (_, k) => DECODE[k]).trim();
32
+ function pageMeta(html) {
33
+ const title = /<title>([\s\S]*?)<\/title>/i.exec(html);
34
+ const desc = /<meta\s+name="description"\s+content="([^"]*)"/i.exec(html);
35
+ return { title: decode(title && title[1]), description: decode(desc && desc[1]) };
36
+ }
37
+
38
+ /** The card's tags for a page, or '' when the page already writes its own. */
39
+ function linkPreviewTags(html, brand = {}, origin = '') {
40
+ const page = String(html == null ? '' : html);
41
+ if (/<meta\s+property="og:title"/i.test(page)) return '';
42
+ const site = (brand.identity && brand.identity.productName) || '';
43
+ const { title, description } = pageMeta(page);
44
+ const t = title || site;
45
+ const d = description || (brand.copy && brand.copy.tagline) || '';
46
+ const image = `${String(origin || '').replace(/\/+$/, '')}${CARD_PATH}`;
47
+ const alt = site ? `The ${site} mark` : 'The mark';
48
+ const tag = (attr, key, value) => `<meta ${attr}="${key}" content="${escapeHtml(value)}">`;
49
+ return [
50
+ tag('property', 'og:type', 'website'),
51
+ site ? tag('property', 'og:site_name', site) : '',
52
+ tag('property', 'og:title', t),
53
+ d ? tag('property', 'og:description', d) : '',
54
+ tag('property', 'og:image', image),
55
+ tag('property', 'og:image:width', String(CARD_SIZE.width)),
56
+ tag('property', 'og:image:height', String(CARD_SIZE.height)),
57
+ tag('property', 'og:image:alt', alt),
58
+ tag('name', 'twitter:card', 'summary_large_image'),
59
+ tag('name', 'twitter:title', t),
60
+ d ? tag('name', 'twitter:description', d) : '',
61
+ tag('name', 'twitter:image', image),
62
+ ].join('');
63
+ }
64
+
65
+ /** The page with the card inserted at the end of its <head> (unchanged when it has one). */
66
+ function applyLinkPreview(html, brand = {}, origin = '') {
67
+ const tags = linkPreviewTags(html, brand, origin);
68
+ return tags && String(html).includes('</head>') ? String(html).replace('</head>', `${tags}</head>`) : String(html);
69
+ }
70
+
71
+ module.exports = { linkPreviewTags, applyLinkPreview, CARD_PATH };
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.52'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.53'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -46,6 +46,11 @@ const path = require('path');
46
46
  const express = require('express');
47
47
  const { mountInternalSurfaces, isStatusHost, isBuildersHost, injectBrandingGlobal } = require('./bongos/serve-internal');
48
48
  const { isApiShapedPath, apiNotFoundBody } = require('./bongos/api-path-404');
49
+ const { errorHandlers } = require('./error-page');
50
+ const { applyLinkPreview } = require('./link-preview');
51
+ const { clientBranding, branding } = require('./branding');
52
+ // The instance's public origin, for an absolute link-preview image URL ('' when unset).
53
+ const safeOrigin = () => { try { return branding().domains.publicOrigin || ''; } catch { return ''; } };
49
54
  const moduleLoader = require('./module-loader/loader');
50
55
  const { start: startErrorTally, startDailySend: startErrorReports } = require('./module-loader/error-tally');
51
56
  const { isModuleEnabled } = require('./modules');
@@ -317,9 +322,14 @@ function buildPlatformApp() {
317
322
  // per request, because modules mount inside mountInternalSurfaces above. No
318
323
  // provider (the module is off, as on every instance but the hub) → next(),
319
324
  // and /raise falls through to the 404 tail like any unknown path.
325
+ // The module renders its own HTML, so the platform's one link-preview card (task
326
+ // 1005091) is added here, on the way out, to any page it sends.
320
327
  app.use('/raise', (req, res, next) => {
321
328
  const raise = isApexHost(req) && seams.resolveOptional('raise.web');
322
329
  if (!raise) return next();
330
+ const send = res.send.bind(res);
331
+ res.send = (body) => send(typeof body === 'string' && body.includes('</head>')
332
+ ? applyLinkPreview(body, clientBranding(), safeOrigin()) : body);
323
333
  Promise.resolve(raise.handle(req, res, next)).catch(next);
324
334
  });
325
335
  // THE ACCOUNT PAGE (task 1004259, ADR 0337 D4.3): where a person edits what
@@ -402,15 +412,19 @@ function buildPlatformApp() {
402
412
  // or the builders' hall lands here. There is NO game to fall through to, so:
403
413
  // * an API-shaped path gets a self-describing JSON 404 (never HTML), the
404
414
  // same contract server.js gives so a mis-prefixed CLI call is legible;
405
- // * everything else gets a plain-text 404 — a game-less instance ships no
406
- // themed island page (public/404.html lives in the game module).
407
- app.use((req, res) => {
408
- res.status(404);
409
- if (isApiShapedPath(req.path)) {
410
- return res.json(apiNotFoundBody(req.path));
411
- }
412
- return res.type('text/plain').send('404 — not found');
415
+ // * a browser asking for a page gets the branded 404 (src/error-page.js,
416
+ // task 1005091), and any other client the plain-text 404 it always had.
417
+ // Then the trailing error net: a page route that throws answers the branded 500
418
+ // to a browser instead of Express's bare "Internal Server Error".
419
+ const { notFound, onError } = errorHandlers({
420
+ brandHtml: injectBrandingGlobal,
421
+ notFoundCopy: () => { try { return clientBranding().copy.notFound; } catch { return 'Page not found.'; } },
422
+ isApiShapedPath,
423
+ apiNotFoundBody,
424
+ log: (err, req) => logger.error({ err, method: req.method, url: req.originalUrl }, 'page request failed'),
413
425
  });
426
+ app.use(notFound);
427
+ app.use(onError);
414
428
 
415
429
  return app;
416
430
  }
@@ -0,0 +1,213 @@
1
+ // tests/brand_surfaces.mjs — the brand outside the hall (task 1005091, goal 1000138):
2
+ // the link-preview card every public page carries, the branded 404 and 500 pages,
3
+ // the themed API docs, the brand colour on Discord posts, and no maker's name written
4
+ // into the portable landing.
5
+ //
6
+ // Behaviour, not spelling: the server is built and asked for pages over HTTP, the
7
+ // error handlers and the link-preview template are called, and the Discord posters
8
+ // send to a stubbed fetch whose bodies are read back.
9
+ //
10
+ // Run: node --test tests/brand_surfaces.mjs
11
+
12
+ import { test, before, after } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import fs from 'node:fs';
15
+ import http from 'node:http';
16
+ import path from 'node:path';
17
+ import { createRequire } from 'node:module';
18
+ import { fileURLToPath } from 'node:url';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const { buildPlatformApp } = require('../src/platform-server.js');
23
+ const { linkPreviewTags, applyLinkPreview, CARD_PATH } = require('../src/link-preview.js');
24
+ const { errorPageTemplate, errorHandlers } = require('../src/error-page.js');
25
+ const { applyBrandTokens, injectBrandingGlobal } = require('../src/bongos/serve-internal.js');
26
+ const { clientBranding } = require('../src/branding.js');
27
+ const webhook = require('../modules/discord/webhook.js');
28
+ const discordLib = require('../scripts/discord/lib.js');
29
+ const { ogCardHtml } = require('../scripts/bongos/gen-brand.js');
30
+
31
+ const BRAND = clientBranding();
32
+ const PRODUCT = BRAND.identity.productName;
33
+
34
+ let server;
35
+ let port;
36
+ before(async () => {
37
+ server = http.createServer(buildPlatformApp());
38
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
39
+ port = server.address().port;
40
+ });
41
+ after(async () => { await new Promise((resolve) => server.close(resolve)); });
42
+
43
+ // node:http, not fetch: fetch drops a Host override.
44
+ function get(p, { host = '127.0.0.1', accept = 'text/html,application/xhtml+xml,*/*;q=0.8' } = {}) {
45
+ return new Promise((resolve, reject) => {
46
+ const req = http.request({ host: '127.0.0.1', port, path: p, headers: { Host: host, Accept: accept } }, (res) => {
47
+ let body = '';
48
+ res.setEncoding('utf8');
49
+ res.on('data', (c) => { body += c; });
50
+ res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'] || '', body }));
51
+ });
52
+ req.on('error', reject);
53
+ req.end();
54
+ });
55
+ }
56
+
57
+ // ── the link-preview card ───────────────────────────────────────────────────────
58
+ test('the card is built from the page\'s own title and description, with the shared image', () => {
59
+ const html = '<html><head><title>Status &amp; more — Acme</title><meta name="description" content="What &quot;shipped&quot;."></head><body></body></html>';
60
+ const brand = { identity: { productName: 'Acme' }, copy: { tagline: 'unused' } };
61
+ const tags = linkPreviewTags(html, brand, 'https://acme.example/');
62
+ assert.ok(tags.includes('<meta property="og:title" content="Status &amp; more — Acme">'), 'escaped once, not twice');
63
+ assert.ok(tags.includes('<meta property="og:description" content="What &quot;shipped&quot;.">'));
64
+ assert.ok(tags.includes(`<meta property="og:image" content="https://acme.example${CARD_PATH}">`), 'absolute when the instance has an origin');
65
+ assert.ok(tags.includes('<meta name="twitter:card" content="summary_large_image">'));
66
+ assert.ok(tags.includes('<meta property="og:site_name" content="Acme">'));
67
+ assert.ok(linkPreviewTags('<title>x</title>', {}, '').includes(`content="${CARD_PATH}"`), 'root-relative with no origin');
68
+ // a page that writes its own card keeps it
69
+ assert.equal(linkPreviewTags('<head><meta property="og:title" content="own"></head>', brand, ''), '');
70
+ const applied = applyLinkPreview(html, brand, '');
71
+ assert.ok(applied.indexOf('og:image') < applied.indexOf('</head>'));
72
+ });
73
+
74
+ test('every served public page carries the card: status, docs, and anything branded', async () => {
75
+ const status = await get('/', { host: 'status.example.com' });
76
+ assert.equal(status.status, 200);
77
+ assert.match(status.body, new RegExp(`<meta property="og:image" content="[^"]*${CARD_PATH.replace(/[.]/g, '\\.')}">`));
78
+ assert.match(status.body, /<meta property="og:title" content="[^"]*build status">/);
79
+ const docs = await get('/docs');
80
+ assert.equal(docs.status, 200);
81
+ assert.match(docs.body, /<meta name="twitter:card" content="summary_large_image">/);
82
+ // the landing and hub pages go through the same injection
83
+ const landingLike = injectBrandingGlobal('<html><head><title>{{productName}} — community</title></head><body></body></html>', BRAND, {});
84
+ assert.ok(landingLike.includes(`<meta property="og:title" content="${PRODUCT} — community">`));
85
+ });
86
+
87
+ test('the card image is a 1200x630 PNG drawn from the shared mark', () => {
88
+ const png = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'public', 'brand', 'og-card.png'));
89
+ assert.equal(png.toString('ascii', 1, 4), 'PNG');
90
+ assert.deepEqual([png.readUInt32BE(16), png.readUInt32BE(20)], [1200, 630]);
91
+ const page = ogCardHtml({ markSvg: '<svg/>', name: 'Acme <Co>', fontLinks: ['https://f.example/x'], fontFamily: '"Manrope", sans-serif' });
92
+ assert.ok(page.includes('text-transform:lowercase'), 'the wordmark is lowercase (DESIGN.md "Naming")');
93
+ assert.ok(page.includes('Acme &lt;Co&gt;'), 'the name is escaped');
94
+ assert.ok(page.includes('data:image/svg+xml;base64,'), 'the mark is the shared file, embedded');
95
+ });
96
+
97
+ // ── the branded 404 and 500 ────────────────────────────────────────────────────
98
+ test('a browser gets the branded 404; an API path keeps JSON; a non-HTML client keeps text', async () => {
99
+ const page = await get('/no-such-page');
100
+ assert.equal(page.status, 404);
101
+ assert.match(page.type, /text\/html/);
102
+ assert.ok(page.body.includes('<use href="/modules/ui-design/public/brand/mark.svg#mark"/>'), 'the shared mark');
103
+ assert.ok(page.body.includes(`<title>${BRAND.copy.notFound} · ${PRODUCT}</title>`), 'the pack\'s words and the product name');
104
+ assert.ok(page.body.includes(BRAND.theme.fonts.links[0].replace(/&/g, '&amp;')), 'the pack\'s faces');
105
+ assert.doesNotMatch(page.body, /\{\{/);
106
+
107
+ const api = await get('/api/bongos/no-such-thing', { accept: 'text/html' });
108
+ assert.equal(api.status, 404);
109
+ assert.match(api.type, /application\/json/);
110
+
111
+ const curl = await get('/no-such-page', { accept: 'text/plain' });
112
+ assert.equal(curl.status, 404);
113
+ assert.match(curl.type, /text\/plain/);
114
+ });
115
+
116
+ test('a page route that throws answers the branded 500 to a browser and JSON to the API', () => {
117
+ const logged = [];
118
+ const { onError } = errorHandlers({
119
+ brandHtml: (h) => applyBrandTokens(h, { identity: { productName: 'Acme' } }, '', '', '', '', ''),
120
+ notFoundCopy: () => 'Gone.', isApiShapedPath: (p) => p.startsWith('/api/'), apiNotFoundBody: () => ({}),
121
+ log: (err) => logged.push(err.message),
122
+ });
123
+ const res = () => {
124
+ const r = { headersSent: false, code: 0, ctype: '', body: null };
125
+ r.status = (c) => { r.code = c; return r; };
126
+ r.type = (t) => { r.ctype = t; return r; };
127
+ r.send = (b) => { r.body = b; return r; };
128
+ r.json = (b) => { r.ctype = 'json'; r.body = b; return r; };
129
+ return r;
130
+ };
131
+ const req = (p, accept) => ({ path: p, method: 'GET', accepts: (t) => (Array.isArray(t) ? (accept === 'html' ? 'html' : 'json') : accept === 'html') });
132
+ const page = res();
133
+ onError(new Error('boom'), req('/projects', 'html'), page, () => {});
134
+ assert.equal(page.code, 500);
135
+ assert.equal(page.ctype, 'html');
136
+ assert.ok(page.body.includes('Something went wrong on our side.') && page.body.includes('· Acme</title>'));
137
+ assert.doesNotMatch(page.body, /boom/, 'the error text never reaches the page');
138
+ const api = res();
139
+ onError(new Error('boom'), req('/api/bongos/x', 'html'), api, () => {});
140
+ assert.equal(api.ctype, 'json');
141
+ assert.equal(api.body.error.code, 'internal_error');
142
+ assert.deepEqual(logged, ['boom', 'boom']);
143
+ // the 404 template carries the pack's words for "not found"
144
+ assert.ok(errorPageTemplate(404, { notFound: 'Gone.' }).includes('<h1>Gone.</h1>'));
145
+ });
146
+
147
+ test('the error page and the docs write their dark tier twice, byte-identical', () => {
148
+ const twins = (src) => {
149
+ const a = src.match(/html\[data-theme="dark"\]\s*\{([^}]*)\}/);
150
+ const b = src.match(/@media \(prefers-color-scheme:\s*dark\)\s*\{\s*html:not\(\[data-theme\]\)\s*\{([^}]*)\}/);
151
+ assert.ok(a && b, 'both twins exist');
152
+ const norm = (s) => s.replace(/\s+/g, ' ').trim();
153
+ assert.equal(norm(b[1]), norm(a[1]));
154
+ };
155
+ twins(errorPageTemplate(404));
156
+ twins(fs.readFileSync(path.join(ROOT, 'public-docs', 'index.html'), 'utf8'));
157
+ });
158
+
159
+ // ── the API docs ───────────────────────────────────────────────────────────────
160
+ test('the API docs load the brand: the scales, the mark, the pack\'s faces and a token theme for Redoc', async () => {
161
+ const r = await get('/docs');
162
+ assert.equal(r.status, 200);
163
+ assert.ok(r.body.includes('<link rel="stylesheet" href="/modules/ui-design/public/tokens.css">'));
164
+ assert.ok(r.body.includes('<use href="/modules/ui-design/public/brand/mark.svg#mark"/>'));
165
+ assert.ok(r.body.includes(BRAND.theme.fonts.links[0].replace(/&/g, '&amp;')), 'the pack\'s font link is spliced');
166
+ assert.match(r.body, /Redoc\.init\('\/docs\/openapi\.json', \{[^}]*theme: theme \}/);
167
+ assert.doesNotMatch(r.body, /system-ui, -apple-system, Segoe UI, Roboto/, 'the old system-font bar is gone');
168
+ assert.doesNotMatch(r.body, /#2b6cb0|#1a1a1a/, 'no off-pack colour');
169
+ });
170
+
171
+ // ── Discord ────────────────────────────────────────────────────────────────────
172
+ async function capture(fn, envKey) {
173
+ const sent = [];
174
+ const realFetch = globalThis.fetch;
175
+ const prev = process.env[envKey];
176
+ process.env[envKey] = 'https://discord.example/api/webhooks/1/x';
177
+ globalThis.fetch = async (url, init) => { sent.push(JSON.parse(init.body)); return { ok: true, status: 200, json: async () => ({ id: '1' }), text: async () => '' }; };
178
+ try { await fn(); } finally {
179
+ globalThis.fetch = realFetch;
180
+ if (prev === undefined) delete process.env[envKey]; else process.env[envKey] = prev;
181
+ }
182
+ return sent;
183
+ }
184
+
185
+ test('a Discord broadcast is an embed in the pack\'s accent (a sample post)', async () => {
186
+ const accent = parseInt(BRAND.theme.colors.accent.slice(1), 16);
187
+ const sample = webhook.embedBody('**Shipped:** the status page now matches the hall.');
188
+ assert.deepEqual(sample, {
189
+ embeds: [{ description: '**Shipped:** the status page now matches the hall.', color: accent }],
190
+ allowed_mentions: { parse: [] },
191
+ });
192
+ assert.equal(webhook.embedBody('x', { theme: { colors: { accent: 'not-a-colour' } } }).embeds[0].color, undefined, 'no bar rather than a wrong one');
193
+ assert.equal(webhook.embedBody('y'.repeat(5000)).embeds[0].description.length, 4096);
194
+
195
+ const [moduleBody] = await capture(() => webhook.postWebhook('ship_news', 'shipped'), 'DISCORD_SHIP_NEWS_WEBHOOK');
196
+ assert.deepEqual(moduleBody, { embeds: [{ description: 'shipped', color: accent }], allowed_mentions: { parse: [] } });
197
+ const [coreBody] = await capture(() => discordLib.postWebhook('ship_news', 'shipped'), 'DISCORD_SHIP_NEWS_WEBHOOK');
198
+ assert.deepEqual(coreBody, moduleBody, 'the core twin posts the same embed');
199
+ });
200
+
201
+ // ── the portable landing names no maker ─────────────────────────────────────────
202
+ test('the landing names its maker only from the pack, and drops the band when there is none', () => {
203
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'public-landing', 'public', 'index.html'), 'utf8');
204
+ const code = src.replace(/<!--(?!\/?brand-if)[\s\S]*?-->/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
205
+ assert.doesNotMatch(code, /Verro|VERRO/, 'no maker\'s name written into the portable page');
206
+ const stamp = (name) => applyBrandTokens(src, { identity: { productName: 'Acme', worldName: 'Acme', company: { name, site: '', tagline: '' } } }, '', '', '', '', '');
207
+ const named = stamp('Rocket Labs');
208
+ assert.ok(named.includes('<p class="wordmark">Rocket Labs</p>'));
209
+ assert.ok(named.includes('Built by Rocket Labs, Acme is our way'));
210
+ assert.ok(named.includes('held by Rocket Labs or its pilot clients'));
211
+ const none = stamp('');
212
+ assert.doesNotMatch(none, /class="wrap verro"|held by|Built by ,/, 'the band and the line drop whole');
213
+ });
@@ -132,16 +132,29 @@ test('the emitter fires on FRESH opens only, and the listener is registered (sou
132
132
  assert.ok(manifest.consumes.includes('government'), 'the port consumption is declared');
133
133
  });
134
134
 
135
- test('#871 survives: the mention poster keeps parse:[] and rings only the explicit allowlist', () => {
136
- const src = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'webhook.js'), 'utf8');
137
- const block = src.slice(src.indexOf('async function postWebhookMentions'));
138
- assert.match(block, /postCore\(channel, text, \{ parse: \[\], users \}\)/,
139
- 'content can never ping on its own — only the server-resolved ids ring');
140
- const plain = src.slice(src.indexOf('async function postWebhook('), src.indexOf('async function postWebhookMentions'));
141
- assert.match(plain, /postCore\(channel, text, \{ parse: \[\] \}\)/,
142
- 'the default poster stays fully suppressed — #871 verbatim');
143
- assert.match(block, /slice\(0, 20\)/, 'the allowlist is sanity-bounded');
144
- assert.match(block, /\^\\d\+\$/, 'only numeric snowflake ids survive the filter');
135
+ test('#871 survives: the mention poster keeps parse:[] and rings only the explicit allowlist', async () => {
136
+ // Drive the two posters against a stubbed fetch and read the bodies they send
137
+ // (task 1005091 moved the plain poster onto an embed; the rule did not move).
138
+ const sent = [];
139
+ const realFetch = globalThis.fetch;
140
+ const prev = process.env.DISCORD_BOARD_ROOM_WEBHOOK;
141
+ process.env.DISCORD_BOARD_ROOM_WEBHOOK = 'https://discord.example/api/webhooks/1/x';
142
+ globalThis.fetch = async (url, init) => { sent.push(JSON.parse(init.body)); return { ok: true, status: 200, json: async () => ({ id: '9' }) }; };
143
+ try {
144
+ await webhook.postWebhookMentions('board_room', 'vote <@1> @everyone', ['111', '222', 'not-an-id', ...Array.from({ length: 30 }, (_, i) => String(1000 + i))]);
145
+ await webhook.postWebhook('board_room', 'plain @everyone');
146
+ } finally {
147
+ globalThis.fetch = realFetch;
148
+ if (prev === undefined) delete process.env.DISCORD_BOARD_ROOM_WEBHOOK; else process.env.DISCORD_BOARD_ROOM_WEBHOOK = prev;
149
+ }
150
+ const [ring, plain] = sent;
151
+ assert.deepEqual(ring.allowed_mentions.parse, [], 'content can never ping on its own — only the server-resolved ids ring');
152
+ assert.equal(ring.content, 'vote <@1> @everyone', 'the mention post stays message text, so its ids can ring');
153
+ assert.equal(ring.allowed_mentions.users.length, 20, 'the allowlist is sanity-bounded');
154
+ assert.ok(ring.allowed_mentions.users.every((u) => /^\d+$/.test(u)), 'only numeric snowflake ids survive the filter');
155
+ assert.deepEqual(plain.allowed_mentions, { parse: [] }, 'the default poster stays fully suppressed — #871 verbatim');
156
+ assert.equal(plain.content, undefined, 'the default poster sends an embed, not message text');
157
+ assert.equal(plain.embeds[0].description, 'plain @everyone');
145
158
  assert.ok(webhook.CHANNELS.has('board_room'), 'the channel key exists');
146
159
  });
147
160
 
@@ -67,7 +67,8 @@ test('/docs Redoc page points at the same-origin spec URL it now always serves',
67
67
  const res = await fetch(base + '/docs');
68
68
  assert.equal(res.status, 200);
69
69
  const html = await res.text();
70
- assert.match(html, /spec-url="\/docs\/openapi\.json"/, 'Redoc host page fetches /docs/openapi.json');
70
+ // task 1005091: Redoc is started by Redoc.init with the page's theme, so the URL is its first argument
71
+ assert.match(html, /Redoc\.init\('\/docs\/openapi\.json'/, 'Redoc host page fetches /docs/openapi.json');
71
72
  });
72
73
  });
73
74