From ada2309721726aec9582110c9fc8c9c4e64d3e23 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:59:39 +0200 Subject: [PATCH 1/8] fix(web): serve the public JSON data files no-cache, not immutable The static plugin gave every file under public/ a year of `immutable`, including the JSON the page fetches at runtime: whats-new.json (rewritten every release), the new security-notices.json (edited by hand) and manifest.json. None of them is content-hashed or `?v=`-busted, so a browser could keep last release's copy, and a security notice added in a later release might never reach it. A `.json` file now revalidates (`no-cache`); every other static asset keeps its immutable year, and a route that set its own Cache-Control still wins. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/server.ts | 11 ++++++++++- test/static-cache-headers.test.ts | 14 +++++++++++++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/web/server.ts b/src/web/server.ts index f388268c..c26366e3 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -964,7 +964,7 @@ export class WebServer extends EventEmitter { // `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and // NOT `res.setHeader()`. A v9-style body throws TypeError on every static // request, which is every page load. See the v10.0.0 release notes. - setHeaders: (reply) => { + setHeaders: (reply, filePath) => { // ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this // callback wrote to the raw response and Fastify's staged reply headers then // overwrote it, so a route that set its own Cache-Control before .sendFile() @@ -973,6 +973,15 @@ export class WebServer extends EventEmitter { // no-store` its route asks for — a service worker that can never update. // So: a route that already decided keeps its answer. if (reply.getHeader('Cache-Control') !== undefined) return; + // A JSON file here is DATA the page fetches at runtime (whats-new.json is rewritten every + // release, security-notices.json by hand, manifest.json), never content-hashed and never + // `?v=`-busted, so it must revalidate. Under `immutable` a browser may keep last release's + // copy for a year, and the page's `fetch(..., { cache: 'no-cache' })` is the only thing + // asking it not to; the response header is what every browser honours. + if (filePath.endsWith('.json')) { + reply.header('Cache-Control', 'no-cache'); + return; + } reply.header('Cache-Control', 'public, max-age=31536000, immutable'); }, }); diff --git a/test/static-cache-headers.test.ts b/test/static-cache-headers.test.ts index eec9a2fc..c92546b0 100644 --- a/test/static-cache-headers.test.ts +++ b/test/static-cache-headers.test.ts @@ -11,7 +11,8 @@ * * That contract is load-bearing: assets are served `immutable` for a year, and * `index.html` must revalidate every time or a deploy leaves browsers on stale - * markup (see `cacheBustAssets` in server.ts). No HTML is left behind the static + * markup (see `cacheBustAssets` in server.ts). The JSON data files the page fetches at + * runtime (whats-new.json, security-notices.json) revalidate too. No HTML is left behind the static * plugin (every page has its own route), so the HTML half is asserted on the route. * * These tests drive a REAL WebServer on purpose. Asserting against an inline @@ -73,6 +74,17 @@ describe('static asset Cache-Control headers', () => { expect(res.headers.get('cache-control')).toBe('no-cache, no-store'); }); + it('makes the runtime JSON data files revalidate instead of pinning them for a year', async () => { + // whats-new.json is rewritten every release and security-notices.json by hand; neither is + // content-hashed or `?v=`-busted, so an `immutable` copy could outlive the release that + // replaced it, and a security notice added later would never reach that browser. + for (const file of ['security-notices.json', 'whats-new.json', 'manifest.json']) { + const res = await get(`${baseUrl}/${file}`); + expect(res.status, file).toBe(200); + expect(res.headers.get('cache-control'), file).toBe('no-cache'); + } + }); + it('serves the rendered index with no-cache too, via its own route', async () => { // Different code path from the above, same contract: index.html is generated per // request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would From 50bae2d3715f06ebab7d7a5ada496459611da024 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:59:39 +0200 Subject: [PATCH 2/8] feat(ui): security notices, shown only to users of the affected CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hand-maintained src/web/public/security-notices.json carries warnings about things outside Codeman that its users should act on. The first: OpenCode's report of unauthorized access to its legacy Console, which may have exposed provider API keys saved there. - Pure core in constants.js (CodemanSecurityNotices): select() keeps the notices that apply now (inclusive local-day dates, not yet seen, https links, malformed entries skipped), and one naming a CLI only when window.__codemanCliAvailable[cli] === true. Unknown means not installed: a warning about software you do not have is noise. The CLI id is data in the JSON; no code branches on it. plan() decides the surface, so a load shows each notice once. - An existing install on a new version gets the notice INSIDE the release's update toast: the highlights as before, a warning section, and the notice's link next to "See what's new". With no notice the update toast is the plain one it always was. - Everyone else (a fresh install, a same-version reload) gets its own card in the toast stack: shield, "Security notice" eyebrow, title, message, the link, Dismiss and the ×; amber-to-red accent bar, theme tokens only (dark and light skins), full width on phones, an opacity/transform entrance and none under prefers-reduced-motion, role=alert. - A notice is recorded as seen only when the user closes it, dismisses it or follows its link, never when it appears. Storage blocked shows nothing. The notice's own text is textContent and carries its own zh-CN, so i18n.js leaves it alone; the card's own copy translates. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/web/public/constants.js | 149 ++++++++++++++++ src/web/public/i18n.js | 3 + src/web/public/panels-ui.js | 174 +++++++++++++++++-- src/web/public/security-notices.json | 21 +++ src/web/public/settings-ui.js | 108 ++++++++++-- src/web/public/styles.css | 248 +++++++++++++++++++++++++++ 6 files changed, 677 insertions(+), 26 deletions(-) create mode 100644 src/web/public/security-notices.json diff --git a/src/web/public/constants.js b/src/web/public/constants.js index c5c0491d..a459409c 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -3436,3 +3436,152 @@ if (typeof window !== 'undefined') { TERMINAL_MIN_ROWS, }; } + +// ═══════════════════════════════════════════════════════════════ +// Security notices +// ═══════════════════════════════════════════════════════════════ +// A security notice is a sticky warning toast about something outside Codeman that +// its users should act on (the first: OpenCode's Console breach, October 2026). The +// notices are DATA in the hand-maintained src/web/public/security-notices.json, never +// in whats-new.json (`npm run version-packages` rewrites that one every release), and +// they ship with a release. This is the pure half: which notices this browser shows, and +// on which surface. The wiring (fetches, the seen lists) is checkWhatsNew() in settings-ui.js, +// the cards are showSecurityNotice() / showUpdateNotice() in panels-ui.js. + +/** `YYYY-MM-DD`, and a real calendar day (no 2026-02-30). */ +const SECURITY_NOTICE_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; +/** An https URL with a plain host: no other scheme, no userinfo, no whitespace. */ +const SECURITY_NOTICE_URL_PATTERN = /^https:\/\/[A-Za-z0-9.-]+(?::\d{1,5})?(?:[/?#]\S*)?$/; + +/** + * The local-time bounds of one `YYYY-MM-DD` day, or null when the string is not one. + * Local time on purpose: a notice dated the 18th starts on the viewer's 18th. + */ +function securityNoticeDay(value) { + const match = typeof value === 'string' ? SECURITY_NOTICE_DATE_PATTERN.exec(value) : null; + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const day = Number(match[3]); + const start = new Date(year, month, day); + if (start.getFullYear() !== year || start.getMonth() !== month || start.getDate() !== day) return null; + const end = new Date(year, month, day + 1).getTime() - 1; + return { start: start.getTime(), end }; +} + +function securityNoticeText(value) { + return typeof value === 'string' && value.trim() ? value.trim() : ''; +} + +/** + * The notices to show, in file order, each as `{ id, title, message, label, url }` + * (`title` is '' when the entry has none). + * + * - A malformed entry is skipped, never repaired: no id, message or link label, a + * link that is not https, a date that is not a real `YYYY-MM-DD` day, a `title` + * that is present but not a non-blank string, or a `cli` that is not a registry + * id. A repeated id shows its first entry only. + * - Shown from the start of the `published` day to the END of the `expires` day. + * The expiry is what keeps an old notice from alarming a browser that first opens + * Codeman months later. + * - An id in `seen` is skipped (the user closed it, or followed its link). + * - A notice naming a `cli` is shown ONLY when `available[cli] === true`. Absent, + * unknown and false all mean "not installed" here, unlike isCliAvailable() in + * settings-ui.js (which offers a launch on a doubt): a warning about software you + * do not have is noise. A notice with no `cli` is for everyone. + * - `lang` 'zh-CN' takes the entry's `i18n['zh-CN']` title, message and label where + * they exist, each falling back to the English one on its own. + * + * The CLI id is data from the JSON, compared as a key; no code here names one. + * + * @param {Array} notices - `notices` from security-notices.json + * @param {{available?: Record, seen?: Iterable, now?: number, lang?: string}} [opts] + * `available` is window.__codemanCliAvailable, `now` epoch milliseconds (default Date.now()). + * @returns {Array<{id: string, title: string, message: string, label: string, url: string}>} + */ +function selectSecurityNotices(notices, opts = {}) { + if (!Array.isArray(notices)) return []; + const available = opts.available && typeof opts.available === 'object' ? opts.available : {}; + const seenSource = opts.seen; + const seen = new Set( + seenSource && typeof seenSource !== 'string' && typeof seenSource[Symbol.iterator] === 'function' + ? Array.from(seenSource, String) + : [] + ); + const now = typeof opts.now === 'number' && Number.isFinite(opts.now) ? opts.now : Date.now(); + const lang = opts.lang === 'zh-CN' ? 'zh-CN' : 'en'; + const taken = new Set(); + const shown = []; + for (const entry of notices) { + if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue; + const id = securityNoticeText(entry.id); + const title = securityNoticeText(entry.title); + const message = securityNoticeText(entry.message); + const link = entry.link && typeof entry.link === 'object' ? entry.link : null; + const label = securityNoticeText(link?.label); + const url = typeof link?.url === 'string' ? link.url.trim() : ''; + const published = securityNoticeDay(entry.published); + const expires = securityNoticeDay(entry.expires); + if (!id || !message || !label || !SECURITY_NOTICE_URL_PATTERN.test(url) || !published || !expires) continue; + if (entry.title !== undefined && !title) continue; + if (entry.cli !== undefined && (typeof entry.cli !== 'string' || !CLI_ID_PATTERN.test(entry.cli))) continue; + if (taken.has(id)) continue; + taken.add(id); + if (now < published.start || now > expires.end) continue; + if (seen.has(id)) continue; + if (entry.cli !== undefined && available[entry.cli] !== true) continue; + const localized = lang === 'en' ? null : entry.i18n?.[lang]; + shown.push({ + id, + title: securityNoticeText(localized?.title) || title, + message: securityNoticeText(localized?.message) || message, + label: securityNoticeText(localized?.label) || label, + url, + }); + } + return shown; +} + +/** + * Which surface carries each selected notice on this page load, decided together with + * the once-per-release update toast (checkWhatsNew in settings-ui.js) so a browser gets + * ONE notification, never a card and a toast for the same notice. + * + * - The update toast shows exactly as checkWhatsNew always decided: this browser saw an + * older version (`seenVersion` set and different), and `notes` are this version's with + * at least one highlight. Then every selected notice rides INSIDE it (`updateNotices`) + * and no standalone card is shown. + * - Otherwise (a fresh install, a reload on the same version, notes missing or empty) + * each notice gets its own card (`cards`). + * - `recordVersion`: store `runningVersion` as seen, as checkWhatsNew always did: on a + * fresh install (so it is not greeted with "what's new") and when the toast shows. + * Notes that are missing or empty record nothing, so the next load tries again. + * + * @param {{seenVersion?: string|null, runningVersion: string, notes?: object|null, notices?: Array}} state + * `notices` is the output of selectSecurityNotices(). + * @returns {{recordVersion: boolean, update: boolean, updateNotices: Array, cards: Array}} + */ +function planStartupNotices(state = {}) { + const seenVersion = typeof state.seenVersion === 'string' && state.seenVersion ? state.seenVersion : null; + const runningVersion = state.runningVersion; + const notices = Array.isArray(state.notices) ? state.notices : []; + const notes = state.notes; + const update = Boolean( + seenVersion && + seenVersion !== runningVersion && + notes && + notes.version === runningVersion && + Array.isArray(notes.highlights) && + notes.highlights.length > 0 + ); + return { + recordVersion: !seenVersion || update, + update, + updateNotices: update ? notices.slice() : [], + cards: update ? [] : notices.slice(), + }; +} + +if (typeof window !== 'undefined') { + window.CodemanSecurityNotices = { select: selectSecurityNotices, plan: planStartupNotices }; +} diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 42657a23..4dcef037 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -844,6 +844,9 @@ 'Settings saved': '设置已保存', // The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English. "See what's new": '查看更新内容', + // The security-notice cards' own eyebrow (panels-ui.js showSecurityNotice/showUpdateNotice); + // each notice's title, message and link label carry their own zh-CN text and are skipped. + 'Security notice': '安全提示', 'Settings applied': '设置已应用', 'Save and keep Settings open': '保存并保持设置打开', 'Settings saved locally': '设置已保存到本机', diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 2d447dbc..630721cd 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -6249,16 +6249,7 @@ Object.assign(CodemanApp.prototype, { closeBtn.onclick = (e) => { e.stopPropagation(); dismiss(); }; toast.appendChild(closeBtn); - // Cache toast container reference - if (!this._toastContainer) { - this._toastContainer = document.querySelector('.toast-container'); - if (!this._toastContainer) { - this._toastContainer = document.createElement('div'); - this._toastContainer.className = 'toast-container'; - document.body.appendChild(this._toastContainer); - } - } - this._toastContainer.appendChild(toast); + this._toastStack().appendChild(toast); requestAnimationFrame(() => toast.classList.add('show')); @@ -6272,6 +6263,169 @@ Object.assign(CodemanApp.prototype, { return { dismiss, setMessage: (text) => { msgSpan.textContent = text; } }; }, + /** The fixed toast stack (`.toast-container`), created on first use and cached. */ + _toastStack() { + if (!this._toastContainer) { + this._toastContainer = document.querySelector('.toast-container'); + if (!this._toastContainer) { + this._toastContainer = document.createElement('div'); + this._toastContainer.className = 'toast-container'; + document.body.appendChild(this._toastContainer); + } + } + return this._toastContainer; + }, + + /** + * A security notice (security-notices.json) as its own card in the toast stack: shield, + * "Security notice" eyebrow, the notice's title and message, its link as the primary + * button, a quiet Dismiss and the ×. Same container as every toast, so it never fights + * the z-index stack. Sticky: only the user ends it. + * + * The notice's own text (title, message, link label) is already in the UI language, so it + * is set with textContent and carries `data-i18n-skip`; the card's own copy ("Security + * notice", "Dismiss", the × label) is application text and translates as usual. + * + * `opts.onClose(reason)` fires ONCE, and only on a user action: `'link'`, `'dismiss'` or + * `'close'`. Never on the handle's own `dismiss()`, which does not mean the user saw it. + * + * @param {{id: string, title: string, message: string, label: string, url: string}} notice + * @param {{onClose?: (reason: string) => void}} [opts] + */ + showSecurityNotice(notice, opts = {}) { + const card = this._noticeCard('security-notice', opts.onClose); + const main = this._noticeEl('div', 'security-notice-main'); + main.append(this._noticeEl('div', 'security-notice-eyebrow', 'Security notice')); + main.append(...this._noticeTextBlock(notice)); + const actions = this._noticeEl('div', 'security-notice-actions'); + actions.append( + this._noticeLinkButton(notice, () => card.close('link')), + this._noticeButton('security-notice-quiet', 'Dismiss', () => card.close('dismiss')) + ); + main.append(actions); + card.el.append(this._noticeIcon('shield', 'security-notice-icon'), main, card.closeButton); + card.mount(); + return { dismiss: () => card.close(null) }; + }, + + /** + * The once-per-release update toast when security notices ride INSIDE it + * (CodemanSecurityNotices.plan in constants.js decides that; with no notice the plain + * showToast toast is used, unchanged). Styled as the security card: the release text as + * before (translated by the same i18n.js pattern), one warning section per notice, then + * each notice's link button beside "See what's new". + * + * Sticky, since it carries a warning. `onNoticeSeen(id)` records a notice: the × records + * every notice in the card, a notice's link records that one and closes the card. "See + * what's new" opens the release notes and leaves the card up, because the warning in it + * has not been answered yet. + * + * @param {{text: string, releaseUrl: string, notices: Array, onNoticeSeen: (id: string) => void}} opts + */ + showUpdateNotice({ text, releaseUrl, notices, onNoticeSeen }) { + const seen = (id) => { try { onNoticeSeen?.(id); } catch (err) { console.error('[notice] onNoticeSeen failed:', err); } }; + const card = this._noticeCard('security-notice update-notice', (reason) => { + if (reason === 'close') for (const notice of notices) seen(notice.id); + }); + const main = this._noticeEl('div', 'security-notice-main'); + main.append(this._noticeEl('p', 'update-notice-text', text)); + const actions = this._noticeEl('div', 'security-notice-actions'); + for (const notice of notices) { + const section = this._noticeEl('section', 'security-notice-section'); + const head = this._noticeEl('div', 'security-notice-section-head'); + head.append(this._noticeIcon('shield'), this._noticeEl('span', 'security-notice-eyebrow', 'Security notice')); + section.append(head, ...this._noticeTextBlock(notice)); + main.append(section); + actions.append(this._noticeLinkButton(notice, () => { seen(notice.id); card.close('link'); })); + } + actions.append( + this._noticeButton('security-notice-secondary', "See what's new", () => window.open(releaseUrl, '_blank', 'noopener')) + ); + main.append(actions); + card.el.append(this._noticeIcon('update', 'security-notice-icon update-notice-icon'), main, card.closeButton); + card.mount(); + return { dismiss: () => card.close(null) }; + }, + + /** The shell both notice cards share: role=alert, the ×, mount and a once-only close. */ + _noticeCard(className, onUserClose) { + const el = this._noticeEl('div', className); + el.setAttribute('role', 'alert'); + el.setAttribute('aria-live', 'assertive'); + let closed = false; + const close = (reason) => { + if (closed) return; + closed = true; + el.classList.remove('show'); + setTimeout(() => el.remove(), 200); + if (reason && typeof onUserClose === 'function') { + try { onUserClose(reason); } catch (err) { console.error('[notice] onClose failed:', err); } + } + }; + const closeButton = this._noticeButton('security-notice-close', '×', () => close('close')); + closeButton.setAttribute('aria-label', 'Close'); + const mount = () => { + this._toastStack().appendChild(el); + requestAnimationFrame(() => el.classList.add('show')); + }; + return { el, closeButton, close, mount }; + }, + + /** The notice's own title (when it has one) and message: textContent, never translated. */ + _noticeTextBlock(notice) { + const parts = []; + if (notice.title) parts.push(this._noticeEl('div', 'security-notice-title', notice.title, true)); + parts.push(this._noticeEl('p', 'security-notice-body', notice.message, true)); + return parts; + }, + + _noticeLinkButton(notice, after) { + return this._noticeButton( + 'security-notice-primary', + notice.label, + () => { window.open(notice.url, '_blank', 'noopener'); after(); }, + true + ); + }, + + _noticeButton(className, label, onClick, untranslated = false) { + const btn = this._noticeEl('button', className, label, untranslated); + btn.type = 'button'; + btn.onclick = (e) => { e.stopPropagation(); onClick(); }; + return btn; + }, + + _noticeEl(tag, className, text, untranslated = false) { + const el = document.createElement(tag); + el.className = className; + if (text !== undefined) el.textContent = text; + if (untranslated) el.setAttribute('data-i18n-skip', ''); + return el; + }, + + /** Inline SVG icons (no asset): `shield` (with an exclamation) or `update` (arrow up). */ + _noticeIcon(kind, wrapperClass) { + const NS = 'http://www.w3.org/2000/svg'; + const svg = document.createElementNS(NS, 'svg'); + for (const [k, v] of Object.entries({ + viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-width': '2', + 'stroke-linecap': 'round', 'stroke-linejoin': 'round', 'aria-hidden': 'true', focusable: 'false', + })) svg.setAttribute(k, v); + const shapes = kind === 'shield' + ? [['path', { d: 'M12 3 4.5 6v5.2c0 4.5 3.1 8.4 7.5 9.8 4.4-1.4 7.5-5.3 7.5-9.8V6L12 3z' }], ['path', { d: 'M12 8.5v4.5' }], ['path', { d: 'M12 16.5h.01' }]] + : [['circle', { cx: '12', cy: '12', r: '9' }], ['path', { d: 'M12 16.5v-9' }], ['path', { d: 'm8.5 11 3.5-3.5 3.5 3.5' }]]; + for (const [tag, attrs] of shapes) { + const shape = document.createElementNS(NS, tag); + for (const [k, v] of Object.entries(attrs)) shape.setAttribute(k, v); + svg.appendChild(shape); + } + if (!wrapperClass) return svg; + const wrap = this._noticeEl('span', wrapperClass); + wrap.setAttribute('aria-hidden', 'true'); + wrap.appendChild(svg); + return wrap; + }, + /** * A prominent, screen-centred status banner — for the small set of messages that are * genuinely worth interrupting the eye for rather than living in the corner with every diff --git a/src/web/public/security-notices.json b/src/web/public/security-notices.json new file mode 100644 index 00000000..8b0e281b --- /dev/null +++ b/src/web/public/security-notices.json @@ -0,0 +1,21 @@ +{ + "notices": [ + { + "id": "opencode-console-breach-2026-10", + "severity": "warning", + "cli": "opencode", + "published": "2026-10-18", + "expires": "2026-12-31", + "title": "OpenCode Console breach: check your API keys", + "message": "Security notice for OpenCode users: on 17 October OpenCode reported unauthorized access to a legacy OpenCode Console system. Provider API keys some users had saved in a Console workspace (OpenAI, Anthropic and others) may have been exposed. OpenCode emailed the affected users: if you got that email, revoke and replace those keys and check their recent usage. The opencode CLI itself is not reported as affected.", + "link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" }, + "i18n": { + "zh-CN": { + "title": "OpenCode Console 遭入侵:请检查你的 API 密钥", + "message": "OpenCode 用户安全提示:OpenCode 于 10 月 17 日报告,其旧版 OpenCode Console 系统遭到未授权访问。部分用户保存在 Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。OpenCode 已向受影响的用户发送邮件:如果你收到了该邮件,请吊销并更换这些密钥,并检查近期用量。目前没有报告称 opencode CLI 本身受到影响。", + "label": "查看 OpenCode 公告" + } + } + } + ] +} diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 7c8cb2fc..6ed29241 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -378,32 +378,108 @@ Object.assign(CodemanApp.prototype, { * The notes come from /whats-new.json, which `npm run version-packages` regenerates from * CHANGELOG.md for each release. A browser that has never seen any version just records the * current one, so a fresh install is not greeted with "what's new". + * + * The same pass places the security notices (/security-notices.json, see + * _loadSecurityNotices), so a load shows ONE notification per notice: + * CodemanSecurityNotices.plan() (constants.js) puts every selected notice INSIDE the update + * toast when that toast shows (showUpdateNotice), and gives each its own card otherwise, a + * fresh install or a reload on the same version (showSecurityNotice). With no notice + * selected the update toast is the plain one it always was. Storage blocked shows neither: + * nothing could remember a close. */ async checkWhatsNew(runningVersion) { if (this._whatsNewChecked) return; this._whatsNewChecked = true; const KEY = 'codeman-whats-new-seen'; let seen = null; - try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement */ return; } - if (seen === runningVersion) return; + try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement and the notices */ return; } const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } }; - if (!seen) { remember(); return; } - try { - const res = await fetch('/whats-new.json', { cache: 'no-cache' }); - if (!res.ok) return; - const notes = await res.json(); - if (notes?.version !== runningVersion || !Array.isArray(notes.highlights) || !notes.highlights.length) return; + const [notes, notices] = await Promise.all([ + seen && seen !== runningVersion ? this._fetchWhatsNewNotes() : null, + this._loadSecurityNotices(), + ]); + const plan = window.CodemanSecurityNotices.plan({ seenVersion: seen, runningVersion, notes, notices }); + if (plan.recordVersion) remember(); + if (plan.update) { const shown = notes.highlights.slice(0, 3).join(' · '); const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : ''; - remember(); - this.showToast(`Codeman updated to v${runningVersion}: ${shown}${more}`, 'info', { - duration: 20000, - action: { - label: "See what's new", - onClick: () => window.open(`https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`, '_blank', 'noopener'), - }, + const text = `Codeman updated to v${runningVersion}: ${shown}${more}`; + const releaseUrl = `https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`; + if (plan.updateNotices.length) { + this.showUpdateNotice({ + text, + releaseUrl, + notices: plan.updateNotices, + onNoticeSeen: (id) => this._rememberSecurityNotice(id), + }); + } else { + this.showToast(text, 'info', { + duration: 20000, + action: { + label: "See what's new", + onClick: () => window.open(releaseUrl, '_blank', 'noopener'), + }, + }); + } + } + for (const notice of plan.cards) { + this.showSecurityNotice(notice, { onClose: () => this._rememberSecurityNotice(notice.id) }); + } + }, + + /** This release's notes, or null (offline or malformed: try again next load). */ + async _fetchWhatsNewNotes() { + try { + const res = await fetch('/whats-new.json', { cache: 'no-cache' }); + return res.ok ? await res.json() : null; + } catch { + return null; + } + }, + + /** + * The security notices this browser should see now, from the HAND-maintained + * /security-notices.json (docs/releasing.md says how to add and retire one), selected by + * CodemanSecurityNotices.select() in constants.js: dates, the seen list, and for a notice + * naming a CLI, `window.__codemanCliAvailable[cli] === true` only (unknown means not + * installed). [] on any failure, and when storage is blocked (a close could not be + * remembered, so the card would return on every load). + */ + async _loadSecurityNotices() { + let seen; + try { seen = this._readSecurityNoticesSeen(); } catch { /* storage blocked: show nothing */ return []; } + try { + const res = await fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' }); + if (!res.ok) return []; + const data = await res.json(); + return window.CodemanSecurityNotices.select(data?.notices, { + available: window.__codemanCliAvailable, + seen, + now: Date.now(), + lang: window.CodemanI18n?.language || window.__codemanLanguage, }); - } catch { /* offline or malformed: try again next load */ } + } catch { /* offline or malformed: try again next load */ return []; } + }, + + /** The ids the user closed or followed (`codeman-security-notices-seen`). Throws when storage is blocked. */ + _readSecurityNoticesSeen() { + const raw = localStorage.getItem('codeman-security-notices-seen'); + let ids = []; + try { ids = raw ? JSON.parse(raw) : []; } catch { /* a corrupt value counts as nothing seen */ } + return Array.isArray(ids) ? ids.filter((id) => typeof id === 'string') : []; + }, + + /** + * Record a notice as seen. Only a user action calls this (a close, Dismiss, its link), never + * the card appearing, so one shown while nobody was looking comes back on the next load. + * Re-reads first: another tab may have recorded a notice since this one loaded. + */ + _rememberSecurityNotice(id) { + try { + const ids = this._readSecurityNoticesSeen(); + if (!ids.includes(id)) ids.push(id); + localStorage.setItem('codeman-security-notices-seen', JSON.stringify(ids)); + } catch { /* storage blocked since load: it shows again next load */ } }, openAppSettings() { diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 15517d20..77985c48 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -9723,6 +9723,254 @@ kbd { .toast-warning { border-color: rgba(234, 179, 8, 0.4); } .toast-info { border-color: rgba(59, 130, 246, 0.4); } +/* Security notice card (security-notices.json; showSecurityNotice / showUpdateNotice in + panels-ui.js). Its own card in the toast stack, never the plain pill, and in the SAME + container, so it shares the toasts' position and z-index. Theme tokens only, so every skin + reads: the light skins' --yellow is already a dark amber that holds on white, and the + primary button's ink is --bg-card (dark ink on the dark skins' light amber, white ink on + the light skins' dark amber). The entrance animates opacity and transform only, and + nothing moves under prefers-reduced-motion. */ +.security-notice { + position: relative; + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + column-gap: 0.75rem; + align-items: start; + box-sizing: border-box; + width: min(27rem, calc(100vw - 1rem)); + padding: 0.85rem 0.55rem 0.9rem 1.05rem; + background: + linear-gradient(180deg, color-mix(in srgb, var(--yellow) 9%, transparent), transparent 6rem), + var(--bg-card); + border: 1px solid color-mix(in srgb, var(--yellow) 32%, var(--border)); + border-radius: 10px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28), 0 2px 6px rgba(0, 0, 0, 0.16); + color: var(--text); + font-size: 0.8rem; + line-height: 1.45; + overflow: hidden; + pointer-events: auto; + opacity: 0; + transform: translateY(-6px) scale(0.985); + transition: opacity 0.22s ease, transform 0.22s cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.security-notice.show { + opacity: 1; + transform: none; +} + +/* The accent bar: amber running into red down the left edge. */ +.security-notice::before { + content: ''; + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 4px; + background: linear-gradient(180deg, var(--yellow), var(--red)); +} + +.security-notice-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + border-radius: 50%; + color: var(--yellow); + background: color-mix(in srgb, var(--yellow) 15%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--yellow) 30%, transparent); +} + +.security-notice-icon svg { + width: 1.1rem; + height: 1.1rem; +} + +/* The update toast carrying a notice leads with the release, in the accent colour. */ +.security-notice-icon.update-notice-icon { + color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 28%, transparent); +} + +.security-notice-main { + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; + padding-top: 0.1rem; +} + +.security-notice-eyebrow { + color: var(--yellow); + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.09em; + line-height: 1.3; + text-transform: uppercase; +} + +.security-notice-title { + color: var(--text); + font-size: 0.9rem; + font-weight: 700; + line-height: 1.3; + overflow-wrap: anywhere; +} + +.security-notice-body, +.update-notice-text { + margin: 0; + overflow-wrap: anywhere; +} + +.security-notice-body { + color: var(--text-dim); +} + +.update-notice-text { + color: var(--text); +} + +/* One warning section per notice inside the update toast. */ +.security-notice-section { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin-top: 0.4rem; + padding: 0.6rem 0.7rem 0.65rem; + border: 1px solid color-mix(in srgb, var(--yellow) 26%, transparent); + border-radius: 8px; + background: color-mix(in srgb, var(--yellow) 7%, transparent); +} + +.security-notice-section-head { + display: flex; + align-items: center; + gap: 0.4rem; + color: var(--yellow); +} + +.security-notice-section-head svg { + flex-shrink: 0; + width: 0.95rem; + height: 0.95rem; +} + +.security-notice-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.45rem 0.8rem; + margin-top: 0.5rem; +} + +/* The update toast's buttons share their row, and when it wraps (a phone, a long label) + each fills its own, so the two never sit at odd widths one under the other. */ +.update-notice .security-notice-actions { + column-gap: 0.6rem; +} + +.update-notice .security-notice-actions > button { + flex: 1 1 auto; +} + +.security-notice-primary, +.security-notice-secondary, +.security-notice-quiet { + font: inherit; + font-size: 0.78rem; + font-weight: 700; + line-height: 1.2; + cursor: pointer; +} + +.security-notice-primary { + padding: 0.45rem 0.85rem; + border: 1px solid transparent; + border-radius: var(--btn-radius); + background: var(--yellow); + color: var(--bg-card); +} + +.security-notice-primary:hover { + background: color-mix(in srgb, var(--yellow) 86%, var(--text)); +} + +.security-notice-secondary { + padding: 0.45rem 0.85rem; + border: 1px solid var(--border-light); + border-radius: var(--btn-radius); + background: transparent; + color: var(--text); +} + +.security-notice-secondary:hover { + background: var(--control-bg-hover); +} + +.security-notice-quiet { + padding: 0.45rem 0.15rem; + border: none; + background: none; + color: var(--text-dim); + font-weight: 600; +} + +.security-notice-quiet:hover { + color: var(--text); + text-decoration: underline; + text-underline-offset: 3px; +} + +.security-notice-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + margin-top: -0.3rem; + padding: 0; + border: none; + border-radius: 6px; + background: none; + color: var(--text-muted); + font-size: 1.15rem; + line-height: 1; + cursor: pointer; +} + +.security-notice-close:hover { + background: var(--control-bg-hover); + color: var(--text); +} + +.security-notice button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Phones: the full width minus the stack's 0.5rem gutters, and the notice's own button + takes the room left beside Dismiss (wrapping under it when the label is long). */ +@media (max-width: 599px) { + .security-notice { + width: calc(100vw - 1rem); + } + + .security-notice-primary { + flex: 1 1 auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .security-notice { + transform: none; + transition: none; + } +} + /* Notification Bell Button */ .btn-icon-header.btn-notifications { position: relative; From 668a220abb66724f95d0458ac817c6fc521ad4a8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:59:39 +0200 Subject: [PATCH 3/8] test(ui): pin security notice selection, placement and wiring The pure select() and plan() loaded from the shipped constants.js, the shipped JSON itself (unique ids, https links, real dates, every cli a stock registry id, a zh-CN title wherever there is an English one), and the wiring in a JSDOM page: fresh install and same-version reload get the card, an existing install's update toast carries the notice, the update toast without a notice matches master's DOM byte for byte, seen only on a user action, storage blocked shows nothing, base path, zh-CN, and the card's CSS contract (theme tokens, motion, phone width). Co-Authored-By: Claude Opus 5.5 (1M context) --- test/security-notices.test.ts | 872 ++++++++++++++++++++++++++++++++++ 1 file changed, 872 insertions(+) create mode 100644 test/security-notices.test.ts diff --git a/test/security-notices.test.ts b/test/security-notices.test.ts new file mode 100644 index 00000000..3f29df55 --- /dev/null +++ b/test/security-notices.test.ts @@ -0,0 +1,872 @@ +/** + * @fileoverview Security notices: a designed warning card per notice in the HAND-maintained + * src/web/public/security-notices.json (the first: OpenCode's Console breach, October 2026), + * or a warning section INSIDE the once-per-release update toast when that toast shows. + * + * What is pinned, and why it matters: + * - The pure selection (`CodemanSecurityNotices.select`, constants.js), loaded from the + * SHIPPED file: a notice naming a CLI shows ONLY when `available[cli] === true` (absent, + * unknown and false all mean "not installed": a warning about software you do not have + * is noise), a notice with no CLI shows to everyone, dates are inclusive local days + * (start of `published` to the END of `expires`), seen ids are skipped, malformed + * entries and non-https links are dropped rather than repaired, and zh-CN takes the + * entry's own strings field by field. + * - The pure surface plan (`CodemanSecurityNotices.plan`): the update toast shows exactly + * when checkWhatsNew always showed it, every notice then rides inside it, and otherwise + * each notice gets its own card. A notice is never on both surfaces. + * - The shipped JSON itself: it parses, ids are unique, links are https, dates are real + * days, and every `cli` is a real stock registry id (a typo would silently show the + * notice to nobody). + * - The wiring (settings-ui.js checkWhatsNew + panels-ui.js cards): fresh installs and + * same-version reloads get the card, an existing install's update toast carries the + * notice, the update toast WITHOUT a notice is byte-for-byte the toast it was before, + * a notice is recorded as seen only on a user action (never on show), storage blocked + * shows nothing, the notice text is textContent and left alone by i18n.js, and the + * card's own copy is translated. + * + * The real modules run inside a JSDOM window (runScripts: 'outside-only'). Port: none. + */ + +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { STOCK_CLIS } from '../src/config/cli-registry/stock.js'; + +const PUBLIC = join(process.cwd(), 'src/web/public'); +const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8'); +const CONSTANTS = read('constants.js'); +const NOTICES_FILE = JSON.parse(read('security-notices.json')) as { notices: Notice[] }; + +type Notice = Record; +type Shown = { id: string; title: string; message: string; label: string; url: string }; +type SelectOpts = { available?: Record; seen?: Iterable; now?: number; lang?: string }; +type PlanState = { seenVersion?: string | null; runningVersion: string; notes?: unknown; notices?: unknown[] }; +type Plan = { recordVersion: boolean; update: boolean; updateNotices: unknown[]; cards: unknown[] }; +type Core = { select: (notices: unknown, opts?: SelectOpts) => Shown[]; plan: (state: PlanState) => Plan }; + +function loadCore(): Core { + const context = vm.createContext({ window: {}, globalThis: {} }); + vm.runInContext(CONSTANTS, context, { filename: 'constants.js' }); + return (context.window as { CodemanSecurityNotices: Core }).CodemanSecurityNotices; +} + +const core = loadCore(); + +/** Local-time instants, the way the core reads `YYYY-MM-DD` days. */ +const at = (y: number, m: number, d: number, h = 12, min = 0, s = 0, ms = 0) => + new Date(y, m - 1, d, h, min, s, ms).getTime(); +const NOW = at(2026, 10, 20); + +const notice = (over: Notice = {}): Notice => ({ + id: 'n1', + severity: 'warning', + published: '2026-10-18', + expires: '2026-12-31', + message: 'Rotate your keys.', + link: { label: 'Read more', url: 'https://example.com/notice' }, + ...over, +}); + +const ids = (shown: Array<{ id: string }>) => shown.map((n) => n.id); + +describe('CodemanSecurityNotices.select (pure)', () => { + describe('a notice naming a CLI', () => { + const withCli = [notice({ cli: 'somecli' })]; + + it('shows when that CLI is installed (strictly true)', () => { + expect(ids(core.select(withCli, { available: { somecli: true }, now: NOW }))).toEqual(['n1']); + }); + + it('stays hidden when the CLI is not installed', () => { + expect(core.select(withCli, { available: { somecli: false }, now: NOW })).toEqual([]); + }); + + it('stays hidden when availability is unknown: no entry, no map, or a truthy non-true value', () => { + expect(core.select(withCli, { available: { other: true }, now: NOW })).toEqual([]); + expect(core.select(withCli, { now: NOW })).toEqual([]); + expect(core.select(withCli, { available: undefined, now: NOW })).toEqual([]); + expect(core.select(withCli, { available: { somecli: 'true' }, now: NOW })).toEqual([]); + expect(core.select(withCli, { available: { somecli: 1 }, now: NOW })).toEqual([]); + }); + + it('never matches a prototype key as an installed CLI', () => { + expect(core.select([notice({ cli: 'constructor' })], { available: {}, now: NOW })).toEqual([]); + }); + }); + + it('shows a notice without a CLI to everyone, whatever is installed', () => { + expect(ids(core.select([notice()], { now: NOW }))).toEqual(['n1']); + expect(ids(core.select([notice()], { available: { somecli: false }, now: NOW }))).toEqual(['n1']); + }); + + it('returns exactly id, title, message, label and url, trimmed', () => { + const shown = core.select( + [ + notice({ + id: ' n1 ', + title: ' Heads up ', + message: ' Hi. ', + link: { label: ' Go ', url: ' https://example.com/x ' }, + }), + ], + { now: NOW } + ); + expect(shown).toEqual([{ id: 'n1', title: 'Heads up', message: 'Hi.', label: 'Go', url: 'https://example.com/x' }]); + }); + + it('gives a notice without a title an empty one', () => { + expect(core.select([notice()], { now: NOW })[0].title).toBe(''); + }); + + describe('dates (inclusive local days)', () => { + const list = [notice()]; + + it('shows from the very start of the published day', () => { + expect(ids(core.select(list, { now: at(2026, 10, 18, 0, 0, 0, 0) }))).toEqual(['n1']); + expect(core.select(list, { now: at(2026, 10, 18, 0, 0, 0, 0) - 1 })).toEqual([]); + }); + + it('shows until the very end of the expires day, then never again', () => { + expect(ids(core.select(list, { now: at(2026, 12, 31, 23, 59, 59, 999) }))).toEqual(['n1']); + expect(core.select(list, { now: at(2026, 12, 31, 23, 59, 59, 999) + 1 })).toEqual([]); + expect(core.select(list, { now: at(2027, 6, 1) })).toEqual([]); + }); + + it('shows a one-day notice all of that day', () => { + const oneDay = [notice({ published: '2026-10-20', expires: '2026-10-20' })]; + expect(ids(core.select(oneDay, { now: at(2026, 10, 20, 0, 0) }))).toEqual(['n1']); + expect(ids(core.select(oneDay, { now: at(2026, 10, 20, 23, 59, 59, 999) }))).toEqual(['n1']); + }); + + it('falls back to the current time when now is not a finite number', () => { + const always = [notice({ published: '2000-01-01', expires: '2999-12-31' })]; + expect(ids(core.select(always))).toEqual(['n1']); + expect(ids(core.select(always, { now: Number.NaN }))).toEqual(['n1']); + }); + }); + + it('skips the ids the user has already closed or followed', () => { + const list = [notice(), notice({ id: 'n2' })]; + expect(ids(core.select(list, { seen: ['n1'], now: NOW }))).toEqual(['n2']); + expect(ids(core.select(list, { seen: new Set(['n2']), now: NOW }))).toEqual(['n1']); + expect(core.select(list, { seen: ['n1', 'n2'], now: NOW })).toEqual([]); + // A string is not a list of ids (it would iterate as characters). + expect(ids(core.select(list, { seen: 'n1' as unknown as string[], now: NOW }))).toEqual(['n1', 'n2']); + }); + + it('keeps file order and shows a repeated id once, as its first entry', () => { + const list = [notice({ id: 'b' }), notice({ id: 'a' }), notice({ id: 'b', message: 'Second.' })]; + const shown = core.select(list, { now: NOW }); + expect(ids(shown)).toEqual(['b', 'a']); + expect(shown[0].message).toBe('Rotate your keys.'); + }); + + describe('malformed entries are skipped, never repaired', () => { + const cases: Array<[string, Notice | unknown]> = [ + ['no id', notice({ id: undefined })], + ['a blank id', notice({ id: ' ' })], + ['a numeric id', notice({ id: 7 })], + ['no message', notice({ message: undefined })], + ['a blank message', notice({ message: '' })], + ['a blank title', notice({ title: ' ' })], + ['a title that is not a string', notice({ title: 42 })], + ['no link', notice({ link: undefined })], + ['a link that is a string', notice({ link: 'https://example.com/' })], + ['a link without a label', notice({ link: { url: 'https://example.com/' } })], + ['a link without a url', notice({ link: { label: 'Go' } })], + ['an http link', notice({ link: { label: 'Go', url: 'http://example.com/notice' } })], + ['a javascript: link', notice({ link: { label: 'Go', url: 'javascript:alert(1)' } })], + ['a protocol-relative link', notice({ link: { label: 'Go', url: '//example.com/' } })], + ['a link with userinfo', notice({ link: { label: 'Go', url: 'https://user@evil.example/' } })], + ['a link with whitespace inside', notice({ link: { label: 'Go', url: 'https://example.com/a b' } })], + ['no published date', notice({ published: undefined })], + ['no expires date', notice({ expires: undefined })], + ['a month 13', notice({ expires: '2026-13-01' })], + ['a day that does not exist', notice({ expires: '2026-02-30' })], + ['a non-ISO date', notice({ published: '18.10.2026' })], + ['a timestamp, not a day', notice({ published: '2026-10-18T00:00:00Z' })], + ['a cli that is not a string', notice({ cli: 5 })], + ['a cli that is not a registry id', notice({ cli: 'Open Code' })], + ['an array', []], + ['null', null], + ['a string', 'notice'], + ]; + for (const [what, entry] of cases) { + it(`skips ${what}`, () => { + // The good entry beside it proves the bad one is skipped, not the whole list. + expect(ids(core.select([entry, notice({ id: 'ok' })], { available: { somecli: true }, now: NOW }))).toEqual([ + 'ok', + ]); + }); + } + + it('returns nothing for a notices value that is not an array', () => { + expect(core.select(undefined, { now: NOW })).toEqual([]); + expect(core.select({ notices: [notice()] }, { now: NOW })).toEqual([]); + expect(core.select('[]', { now: NOW })).toEqual([]); + }); + }); + + describe('language', () => { + const zh = { 'zh-CN': { title: '注意', message: '请更换密钥。', label: '了解更多' } }; + + it('takes the zh-CN title, message and label under zh-CN', () => { + const [shown] = core.select([notice({ title: 'Heads up', i18n: zh })], { lang: 'zh-CN', now: NOW }); + expect(shown).toMatchObject({ + title: '注意', + message: '请更换密钥。', + label: '了解更多', + url: 'https://example.com/notice', + }); + }); + + it('falls back to English field by field when a translation is missing or blank', () => { + const onlyMessage = { 'zh-CN': { message: '请更换密钥。', label: ' ' } }; + expect( + core.select([notice({ title: 'Heads up', i18n: onlyMessage })], { lang: 'zh-CN', now: NOW })[0] + ).toMatchObject({ title: 'Heads up', message: '请更换密钥。', label: 'Read more' }); + expect(core.select([notice()], { lang: 'zh-CN', now: NOW })[0]).toMatchObject({ + message: 'Rotate your keys.', + label: 'Read more', + }); + expect(core.select([notice({ i18n: 'nope' })], { lang: 'zh-CN', now: NOW })[0].message).toBe('Rotate your keys.'); + }); + + it('stays English in English and in a language it has no strings for', () => { + expect(core.select([notice({ i18n: zh })], { lang: 'en', now: NOW })[0].message).toBe('Rotate your keys.'); + expect(core.select([notice({ i18n: zh })], { now: NOW })[0].message).toBe('Rotate your keys.'); + expect(core.select([notice({ i18n: zh })], { lang: 'fr', now: NOW })[0].message).toBe('Rotate your keys.'); + }); + }); +}); + +describe('CodemanSecurityNotices.plan (pure): which surface carries each notice', () => { + const NOTES = { version: '1.43.0', highlights: ['Alpha'] }; + const A = { id: 'a' }; + const B = { id: 'b' }; + + it('puts every notice inside the update toast when an existing install sees a new version', () => { + expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes: NOTES, notices: [A, B] })).toEqual({ + recordVersion: true, + update: true, + updateNotices: [A, B], + cards: [], + }); + }); + + it('shows the plain update toast when no notice is selected', () => { + expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes: NOTES, notices: [] })).toEqual({ + recordVersion: true, + update: true, + updateNotices: [], + cards: [], + }); + }); + + it('gives a fresh install (no seen version) cards and records the version, with no update toast', () => { + for (const seenVersion of [null, undefined, '']) { + expect(core.plan({ seenVersion, runningVersion: '1.43.0', notes: NOTES, notices: [A] })).toEqual({ + recordVersion: true, + update: false, + updateNotices: [], + cards: [A], + }); + } + }); + + it('gives a reload on the same version cards and records nothing', () => { + expect(core.plan({ seenVersion: '1.43.0', runningVersion: '1.43.0', notes: NOTES, notices: [A] })).toEqual({ + recordVersion: false, + update: false, + updateNotices: [], + cards: [A], + }); + }); + + it('falls back to cards, recording no version, when the notes cannot carry a toast', () => { + for (const notes of [ + null, + undefined, + { version: '1.42.9', highlights: ['Old'] }, + { version: '1.43.0', highlights: [] }, + { version: '1.43.0', highlights: 'Alpha' }, + { version: '1.43.0' }, + ]) { + expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes, notices: [A] })).toEqual({ + recordVersion: false, + update: false, + updateNotices: [], + cards: [A], + }); + } + }); + + it('never shows one notice on both surfaces, and never drops one', () => { + const notices = [A, B]; + for (const seenVersion of [null, '1.42.0', '1.43.0']) { + for (const notes of [null, NOTES, { version: '1.43.0', highlights: [] }]) { + const plan = core.plan({ seenVersion, runningVersion: '1.43.0', notes, notices }); + const all = [...plan.updateNotices, ...plan.cards] as Array<{ id: string }>; + expect(ids(all).sort(), JSON.stringify({ seenVersion, notes })).toEqual(['a', 'b']); + expect(plan.updateNotices.length > 0 && plan.cards.length > 0).toBe(false); + } + } + }); + + it('tolerates a missing notices list', () => { + expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes: NOTES }).updateNotices).toEqual([]); + }); +}); + +describe('the shipped security-notices.json', () => { + const notices = NOTICES_FILE.notices; + const stockIds = new Set(STOCK_CLIS.map((entry) => entry.id as string)); + const DAY = /^\d{4}-\d{2}-\d{2}$/; + const realDay = (value: unknown) => { + if (typeof value !== 'string' || !DAY.test(value)) return false; + const [y, m, d] = value.split('-').map(Number); + const date = new Date(Date.UTC(y, m - 1, d)); + return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d; + }; + const nonBlank = (value: unknown) => typeof value === 'string' && value.trim().length > 0; + + it('is a list of notices with unique, stable ids', () => { + expect(Array.isArray(notices)).toBe(true); + expect(notices.length).toBeGreaterThan(0); + const all = notices.map((n) => n.id); + expect(new Set(all).size).toBe(all.length); + for (const id of all) expect(id).toMatch(/^[a-z0-9][a-z0-9-]*$/); + }); + + it('gives every notice a warning severity, an https link and real dates in order', () => { + for (const n of notices) { + expect(n.severity, String(n.id)).toBe('warning'); + expect(nonBlank(n.message), String(n.id)).toBe(true); + if (n.title !== undefined) expect(nonBlank(n.title), String(n.id)).toBe(true); + const link = n.link as { label?: unknown; url?: unknown }; + expect(nonBlank(link?.label), String(n.id)).toBe(true); + expect(typeof link?.url === 'string' && new URL(link.url).protocol, String(n.id)).toBe('https:'); + expect(realDay(n.published), `${String(n.id)} published`).toBe(true); + expect(realDay(n.expires), `${String(n.id)} expires`).toBe(true); + expect(String(n.expires) >= String(n.published), String(n.id)).toBe(true); + } + }); + + it('names only real stock CLI ids, so a typo cannot hide a notice from everyone', () => { + for (const n of notices) { + if (n.cli === undefined) continue; + expect(stockIds.has(n.cli as string), `${String(n.id)}: cli "${String(n.cli)}"`).toBe(true); + } + }); + + it('translates only into languages the UI has, with every string the English entry has', () => { + for (const n of notices) { + if (n.i18n === undefined) continue; + const langs = n.i18n as Record; + for (const [lang, strings] of Object.entries(langs)) { + expect(lang, String(n.id)).toBe('zh-CN'); + expect(nonBlank(strings.message), `${String(n.id)} ${lang} message`).toBe(true); + expect(nonBlank(strings.label), `${String(n.id)} ${lang} label`).toBe(true); + if (n.title !== undefined) expect(nonBlank(strings.title), `${String(n.id)} ${lang} title`).toBe(true); + } + } + }); + + it('passes the shipped selection on its published day for a browser with its CLI', () => { + // A field the core rejects would make the notice silently show to nobody. + for (const n of notices) { + const [y, m, d] = String(n.published).split('-').map(Number); + const available = typeof n.cli === 'string' ? { [n.cli]: true } : {}; + expect(ids(core.select([n], { available, now: at(y, m, d) })), String(n.id)).toEqual([n.id]); + } + }); + + it('shows the OpenCode notice, with its title, only to OpenCode users', () => { + const breach = notices.find((n) => n.id === 'opencode-console-breach-2026-10'); + expect(breach?.cli).toBe('opencode'); + const [shown] = core.select(notices, { available: { opencode: true }, now: NOW }); + expect(shown).toMatchObject({ + id: 'opencode-console-breach-2026-10', + title: 'OpenCode Console breach: check your API keys', + label: "Read OpenCode's notice", + url: 'https://x.com/opencode/status/2111519637049319818', + }); + expect(core.select(notices, { available: { opencode: true }, now: NOW, lang: 'zh-CN' })[0].title).toBe( + 'OpenCode Console 遭入侵:请检查你的 API 密钥' + ); + expect(core.select(notices, { available: { opencode: false, claude: true }, now: NOW })).toEqual([]); + expect(core.select(notices, { available: {}, now: NOW })).toEqual([]); + expect(core.select(notices, { available: { opencode: true }, now: at(2027, 1, 1) })).toEqual([]); + }); + + it('is fetched by the page and never generated by the release script', () => { + const pkg = readFileSync(join(process.cwd(), 'package.json'), 'utf8'); + expect(pkg).not.toContain('security-notices'); + expect(read('settings-ui.js')).toContain("fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' })"); + }); +}); + +// ─── Wiring: settings-ui.js checkWhatsNew + panels-ui.js notice cards ─── + +const SEEN_KEY = 'codeman-security-notices-seen'; +const VERSION_KEY = 'codeman-whats-new-seen'; +const RUNNING = '1.43.0'; +const NOTES = { version: RUNNING, highlights: ['Alpha', 'Beta', 'Gamma', 'Delta'] }; +const UPDATE_TEXT = 'Codeman updated to v1.43.0: Alpha · Beta · Gamma (+1 more)'; +const RELEASE_URL = 'https://github.com/Ark0N/Codeman/releases/tag/codeman%401.43.0'; +const DATA = { + notices: [ + notice({ + id: 'cli-notice', + cli: 'somecli', + title: 'Keys may be exposed', + i18n: { 'zh-CN': { title: '密钥可能泄露', message: '中文提示。', label: '查看' } }, + }), + ], +}; +/** checkWhatsNew's plain update toast exactly as master rendered it before this feature. */ +const PLAIN_UPDATE_TOAST = + '
Codeman updated to v1.43.0: Alpha · Beta · Gamma (+1 more)' + + '' + + '
'; + +type Reply = { ok: boolean; body?: unknown } | 'throw'; +type WiredApp = { + checkWhatsNew(version: string): Promise; + showSecurityNotice(notice: Shown, opts?: { onClose?: (reason: string) => void }): { dismiss(): void }; +}; + +const windows: Array<{ close(): void }> = []; +afterEach(() => { + for (const win of windows.splice(0)) win.close(); + vi.restoreAllMocks(); +}); + +interface BootOpts { + available?: Record | null; + storage?: 'blocked'; + seenIds?: unknown; + /** The version this browser recorded; undefined = a fresh install. */ + seenVersion?: string; + notices?: Reply; + notes?: Reply; + base?: string; + lang?: 'zh-CN'; + i18n?: boolean; +} + +/** constants.js, i18n.js (optional), settings-ui.js and panels-ui.js in one JSDOM page. */ +function boot(opts: BootOpts = {}) { + const dom = new JSDOM('', { + url: 'http://localhost/', + runScripts: 'outside-only', + pretendToBeVisual: true, + }); + const win = dom.window as unknown as Window & + typeof globalThis & { eval(src: string): void; __App: { prototype: object }; [key: string]: unknown }; + windows.push(win); + if (opts.seenIds !== undefined) win.localStorage.setItem(SEEN_KEY, JSON.stringify(opts.seenIds)); + if (opts.seenVersion !== undefined) win.localStorage.setItem(VERSION_KEY, opts.seenVersion); + if (opts.storage === 'blocked') { + Object.defineProperty(win, 'localStorage', { + configurable: true, + get() { + throw new win.DOMException('blocked', 'SecurityError'); + }, + }); + } + const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = []; + win.fetch = (async (url: string, init?: RequestInit) => { + fetchCalls.push({ url, init }); + const reply: Reply = url.endsWith('/whats-new.json') + ? (opts.notes ?? { ok: true, body: NOTES }) + : (opts.notices ?? { ok: true, body: DATA }); + if (reply === 'throw') throw new TypeError('offline'); + return { ok: reply.ok, json: async () => reply.body }; + }) as unknown as typeof fetch; + const opened: unknown[][] = []; + win.open = ((...args: unknown[]) => { + opened.push(args); + return null; + }) as typeof win.open; + if (opts.base) win.__CODEMAN_BASE__ = opts.base; + if (opts.available !== null) win.__codemanCliAvailable = opts.available ?? { somecli: true }; + if (opts.lang) win.__codemanLanguage = opts.lang; + win.eval( + [ + 'var CodemanApp = function CodemanApp() {};', + CONSTANTS, + opts.i18n ? read('i18n.js') : '', + read('settings-ui.js'), + read('panels-ui.js'), + 'window.__App = CodemanApp;', + ].join('\n') + ); + if (opts.i18n) (win.CodemanI18n as { start(): void }).start(); + const app = Object.create(win.__App.prototype) as WiredApp; + const doc = win.document; + const cards = () => Array.from(doc.querySelectorAll('.security-notice:not(.update-notice)')); + const updateCards = () => Array.from(doc.querySelectorAll('.security-notice.update-notice')); + const plainToasts = () => Array.from(doc.querySelectorAll('.toast')); + const button = (root: Element, cls: string) => root.querySelector(`button.${cls}`)!; + const text = (root: Element, cls: string) => root.querySelector(`.${cls}`)?.textContent ?? null; + const seenIds = () => JSON.parse(dom.window.localStorage.getItem(SEEN_KEY) || 'null') as unknown; + const seenVersion = () => dom.window.localStorage.getItem(VERSION_KEY); + return { + dom, + win, + doc, + app, + fetchCalls, + opened, + cards, + updateCards, + plainToasts, + button, + text, + seenIds, + seenVersion, + }; +} + +/** i18n.js translates inserted DOM from a MutationObserver: give it a tick. */ +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); +const afterClose = () => new Promise((resolve) => setTimeout(resolve, 260)); + +describe('a browser with no update toast this load: the standalone card', () => { + it('greets a fresh install with the card, records the version, and does not fetch the notes', async () => { + const { app, fetchCalls, cards, updateCards, plainToasts, text, button, seenIds, seenVersion } = boot(); + await app.checkWhatsNew(RUNNING); + expect(fetchCalls).toEqual([{ url: '/security-notices.json', init: { cache: 'no-cache' } }]); + expect(seenVersion()).toBe(RUNNING); + expect(plainToasts()).toEqual([]); + expect(updateCards()).toEqual([]); + const [card, ...rest] = cards(); + expect(rest).toEqual([]); + expect(text(card, 'security-notice-eyebrow')).toBe('Security notice'); + expect(text(card, 'security-notice-title')).toBe('Keys may be exposed'); + expect(text(card, 'security-notice-body')).toBe('Rotate your keys.'); + expect(button(card, 'security-notice-primary').textContent).toBe('Read more'); + expect(button(card, 'security-notice-quiet').textContent).toBe('Dismiss'); + expect(button(card, 'security-notice-close').getAttribute('aria-label')).toBe('Close'); + // Nothing is recorded just for appearing. + expect(seenIds()).toBeNull(); + }); + + it('shows it on a reload of the same version while it is unseen, and not once it is seen', async () => { + const unseen = boot({ seenVersion: RUNNING }); + await unseen.app.checkWhatsNew(RUNNING); + expect(unseen.cards()).toHaveLength(1); + expect(unseen.fetchCalls.map((c) => c.url)).toEqual(['/security-notices.json']); + + const seen = boot({ seenVersion: RUNNING, seenIds: ['cli-notice'] }); + await seen.app.checkWhatsNew(RUNNING); + expect(seen.cards()).toEqual([]); + expect(seen.plainToasts()).toEqual([]); + }); + + it('is an accessible, sticky card in the toast stack, built from text only', async () => { + const hostile = { + notices: [ + notice({ + title: 'bold', + message: '', + link: { label: 'Go', url: 'https://example.com/' }, + }), + ], + }; + const { win, doc, app, cards, button } = boot({ notices: { ok: true, body: hostile } }); + const timeout = vi.spyOn(win, 'setTimeout'); + await app.checkWhatsNew(RUNNING); + const [card] = cards(); + expect(card.parentElement?.className).toBe('toast-container'); + expect(card.getAttribute('role')).toBe('alert'); + expect(card.getAttribute('aria-live')).toBe('assertive'); + expect(card.querySelector('.security-notice-icon svg')).not.toBeNull(); + expect(card.querySelector('img, b, i')).toBeNull(); + expect(card.querySelector('.security-notice-body')!.textContent).toBe(''); + for (const btn of card.querySelectorAll('button')) expect(btn.type).toBe('button'); + expect(button(card, 'security-notice-primary').hasAttribute('data-i18n-skip')).toBe(true); + expect(timeout.mock.calls.filter(([, delay]) => Number(delay) > 0)).toEqual([]); + expect(doc.querySelectorAll('.toast-container').length).toBe(1); + }); + + for (const [how, cls, reason] of [ + ['the ×', 'security-notice-close', 'close'], + ['Dismiss', 'security-notice-quiet', 'dismiss'], + ] as const) { + it(`records the notice when the user closes it with ${how} (${reason}), and the next load stays quiet`, async () => { + const first = boot(); + await first.app.checkWhatsNew(RUNNING); + const card = first.cards()[0]; + first.button(card, cls).click(); + first.button(card, cls).click(); // a double click while it fades: recorded once + expect(first.seenIds()).toEqual(['cli-notice']); + await afterClose(); + expect(first.cards()).toEqual([]); + + const next = boot({ seenVersion: RUNNING, seenIds: ['cli-notice'] }); + await next.app.checkWhatsNew(RUNNING); + expect(next.cards()).toEqual([]); + }); + } + + it('opens the link with noopener, records the notice and closes the card', async () => { + const { app, cards, button, opened, seenIds } = boot({ seenIds: ['older-notice'] }); + await app.checkWhatsNew(RUNNING); + button(cards()[0], 'security-notice-primary').click(); + expect(opened).toEqual([['https://example.com/notice', '_blank', 'noopener']]); + expect(seenIds()).toEqual(['older-notice', 'cli-notice']); + await afterClose(); + expect(cards()).toEqual([]); + }); + + it('merges with ids another tab recorded after this page loaded', async () => { + const { dom, app, cards, button, seenIds } = boot(); + await app.checkWhatsNew(RUNNING); + dom.window.localStorage.setItem(SEEN_KEY, JSON.stringify(['from-another-tab'])); + button(cards()[0], 'security-notice-close').click(); + expect(seenIds()).toEqual(['from-another-tab', 'cli-notice']); + }); + + it('records nothing when the card is closed by code rather than by the user', async () => { + const { app } = boot(); + const onClose = vi.fn(); + app + .showSecurityNotice({ id: 'x', title: '', message: 'm', label: 'Go', url: 'https://example.com/' }, { onClose }) + .dismiss(); + await afterClose(); + expect(onClose).not.toHaveBeenCalled(); + }); + + it('takes the card when the notes cannot carry an update toast, and records no version', async () => { + for (const notes of [ + { ok: false } as Reply, + 'throw' as Reply, + { ok: true, body: { version: '9.9.9', highlights: ['x'] } }, + ]) { + const { app, cards, updateCards, plainToasts, seenVersion } = boot({ seenVersion: '1.42.0', notes }); + await app.checkWhatsNew(RUNNING); + expect(cards(), JSON.stringify(notes)).toHaveLength(1); + expect(updateCards()).toEqual([]); + expect(plainToasts()).toEqual([]); + expect(seenVersion()).toBe('1.42.0'); + } + }); + + it('treats a corrupt seen value as nothing seen', async () => { + const { dom, app, cards, button, seenIds } = boot(); + dom.window.localStorage.setItem(SEEN_KEY, '{not json'); + await app.checkWhatsNew(RUNNING); + expect(cards()).toHaveLength(1); + button(cards()[0], 'security-notice-close').click(); + expect(seenIds()).toEqual(['cli-notice']); + }); +}); + +describe('an existing install that sees a new version: the notice rides inside the update toast', () => { + it('shows ONE notification with the release text, the warning section and both buttons', async () => { + const { win, app, cards, updateCards, plainToasts, text, seenIds, seenVersion } = boot({ seenVersion: '1.42.0' }); + const timeout = vi.spyOn(win, 'setTimeout'); + await app.checkWhatsNew(RUNNING); + expect(cards()).toEqual([]); + expect(plainToasts()).toEqual([]); + const [card, ...rest] = updateCards(); + expect(rest).toEqual([]); + expect(card.getAttribute('role')).toBe('alert'); + expect(text(card, 'update-notice-text')).toBe(UPDATE_TEXT); + const section = card.querySelector('.security-notice-section')!; + expect(section.querySelector('svg')).not.toBeNull(); + expect(text(section, 'security-notice-eyebrow')).toBe('Security notice'); + expect(text(section, 'security-notice-title')).toBe('Keys may be exposed'); + expect(text(section, 'security-notice-body')).toBe('Rotate your keys.'); + const labels = Array.from(card.querySelectorAll('.security-notice-actions button')).map((b) => b.textContent); + expect(labels).toEqual(['Read more', "See what's new"]); + // The version is recorded on show, as checkWhatsNew always did; the notice is not. + expect(seenVersion()).toBe(RUNNING); + expect(seenIds()).toBeNull(); + // Sticky: it carries a warning. + expect(timeout.mock.calls.filter(([, delay]) => Number(delay) > 0)).toEqual([]); + }); + + it('records the notice when the user closes it', async () => { + const { app, updateCards, button, seenIds } = boot({ seenVersion: '1.42.0' }); + await app.checkWhatsNew(RUNNING); + button(updateCards()[0], 'security-notice-close').click(); + expect(seenIds()).toEqual(['cli-notice']); + await afterClose(); + expect(updateCards()).toEqual([]); + }); + + it('records the notice and closes when the user follows the notice link', async () => { + const { app, updateCards, button, opened, seenIds } = boot({ seenVersion: '1.42.0' }); + await app.checkWhatsNew(RUNNING); + button(updateCards()[0], 'security-notice-primary').click(); + expect(opened).toEqual([['https://example.com/notice', '_blank', 'noopener']]); + expect(seenIds()).toEqual(['cli-notice']); + await afterClose(); + expect(updateCards()).toEqual([]); + }); + + it('carries two notices as two sections, and a link records only its own notice', async () => { + const two = { + notices: [ + notice({ id: 'first', link: { label: 'First', url: 'https://example.com/1' } }), + notice({ id: 'second', link: { label: 'Second', url: 'https://example.com/2' } }), + ], + }; + const { app, updateCards, opened, seenIds } = boot({ seenVersion: '1.42.0', notices: { ok: true, body: two } }); + await app.checkWhatsNew(RUNNING); + const [card] = updateCards(); + expect(card.querySelectorAll('.security-notice-section')).toHaveLength(2); + const labels = Array.from(card.querySelectorAll('.security-notice-actions button')).map((b) => b.textContent); + expect(labels).toEqual(['First', 'Second', "See what's new"]); + card.querySelectorAll('.security-notice-primary')[1].click(); + expect(opened).toEqual([['https://example.com/2', '_blank', 'noopener']]); + // The other notice was never answered, so it comes back next load (as a card). + expect(seenIds()).toEqual(['second']); + }); + + it('opens the release notes from "See what\'s new" and keeps the unanswered warning up', async () => { + const { app, updateCards, button, opened, seenIds } = boot({ seenVersion: '1.42.0' }); + await app.checkWhatsNew(RUNNING); + button(updateCards()[0], 'security-notice-secondary').click(); + expect(opened).toEqual([[RELEASE_URL, '_blank', 'noopener']]); + expect(seenIds()).toBeNull(); + await afterClose(); + expect(updateCards()).toHaveLength(1); + }); + + it('keeps the update toast exactly as it was when no notice is selected', async () => { + for (const variant of [ + { available: { somecli: false } }, + { available: null }, + { seenIds: ['cli-notice'] }, + { notices: { ok: false } as Reply }, + { notices: 'throw' as Reply }, + ] as BootOpts[]) { + const { win, app, cards, updateCards, plainToasts, opened, seenVersion } = boot({ + seenVersion: '1.42.0', + ...variant, + }); + const timeout = vi.spyOn(win, 'setTimeout'); + await app.checkWhatsNew(RUNNING); + expect(cards(), JSON.stringify(variant)).toEqual([]); + expect(updateCards()).toEqual([]); + const toasts = plainToasts(); + expect(toasts.map((t) => t.outerHTML)).toEqual([PLAIN_UPDATE_TOAST]); + expect(timeout.mock.calls.map(([, delay]) => delay)).toContain(20000); + expect(seenVersion()).toBe(RUNNING); + toasts[0].querySelector('button:not(.toast-close)')!.click(); + expect(opened).toEqual([[RELEASE_URL, '_blank', 'noopener']]); + } + }); +}); + +describe('the gates every surface shares', () => { + it('shows nothing, and fetches nothing, when storage is blocked', async () => { + for (const seenVersion of [undefined, '1.42.0']) { + const { app, fetchCalls, cards, updateCards, plainToasts } = boot({ storage: 'blocked', seenVersion }); + await app.checkWhatsNew(RUNNING); + expect(fetchCalls).toEqual([]); + expect([...cards(), ...updateCards(), ...plainToasts()]).toEqual([]); + } + }); + + it('shows nothing when the CLI is not installed or availability was never injected', async () => { + for (const available of [{ somecli: false }, {}, null]) { + const { app, cards, updateCards, seenIds } = boot({ available }); + await app.checkWhatsNew(RUNNING); + expect([...cards(), ...updateCards()], JSON.stringify(available)).toEqual([]); + expect(seenIds()).toBeNull(); + } + }); + + it('shows nothing when the notices file cannot be fetched or read', async () => { + for (const notices of [{ ok: false }, 'throw', { ok: true, body: { notices: 'x' } }] as Reply[]) { + const { app, cards } = boot({ notices }); + await app.checkWhatsNew(RUNNING); + expect(cards(), JSON.stringify(notices)).toEqual([]); + } + }); + + it('checks once per page load, however many times init runs', async () => { + const { app, fetchCalls, cards } = boot({ seenVersion: RUNNING }); + await app.checkWhatsNew(RUNNING); + await app.checkWhatsNew(RUNNING); + expect(fetchCalls).toHaveLength(1); + expect(cards()).toHaveLength(1); + }); + + it('fetches both files under a reverse-proxy base path, prefixed exactly once', async () => { + const { app, fetchCalls } = boot({ base: '/codeman', seenVersion: '1.42.0' }); + await app.checkWhatsNew(RUNNING); + expect(fetchCalls.map((c) => c.url).sort()).toEqual(['/codeman/security-notices.json', '/codeman/whats-new.json']); + }); +}); + +describe('zh-CN', () => { + it("shows the card in the notice's own Chinese and translates the card's own copy", async () => { + const { app, cards, text, button } = boot({ lang: 'zh-CN', i18n: true }); + await app.checkWhatsNew(RUNNING); + await tick(); + const [card] = cards(); + expect(text(card, 'security-notice-eyebrow')).toBe('安全提示'); + expect(text(card, 'security-notice-title')).toBe('密钥可能泄露'); + expect(text(card, 'security-notice-body')).toBe('中文提示。'); + expect(button(card, 'security-notice-primary').textContent).toBe('查看'); + expect(button(card, 'security-notice-quiet').textContent).toBe('忽略'); + expect(button(card, 'security-notice-close').getAttribute('aria-label')).toBe('关闭'); + for (const cls of ['security-notice-title', 'security-notice-body']) { + expect(card.querySelector(`.${cls}`)!.hasAttribute('data-i18n-skip'), cls).toBe(true); + } + }); + + it('translates the update text and "See what\'s new" inside the combined toast', async () => { + const { app, updateCards, text, button } = boot({ lang: 'zh-CN', i18n: true, seenVersion: '1.42.0' }); + await app.checkWhatsNew(RUNNING); + await tick(); + const [card] = updateCards(); + expect(text(card, 'update-notice-text')).toBe('Codeman 已更新到 v1.43.0:Alpha · Beta · Gamma(另有 1 项)'); + expect(button(card, 'security-notice-secondary').textContent).toBe('查看更新内容'); + expect(text(card, 'security-notice-body')).toBe('中文提示。'); + }); + + it('leaves English notice text untranslated even when it matches a dictionary entry', async () => { + const plain = { + notices: [notice({ message: 'Settings saved', link: { label: 'Dismiss', url: 'https://example.com/' } })], + }; + const { app, cards, text, button } = boot({ lang: 'zh-CN', i18n: true, notices: { ok: true, body: plain } }); + await app.checkWhatsNew(RUNNING); + await tick(); + expect(text(cards()[0], 'security-notice-body')).toBe('Settings saved'); + expect(button(cards()[0], 'security-notice-primary').textContent).toBe('Dismiss'); + }); +}); + +describe('the card styles', () => { + const css = read('styles.css'); + const block = css.slice(css.indexOf('.security-notice {'), css.indexOf('/* Notification Bell Button */')); + + it('lives beside the toast rules and uses theme tokens, not fixed colours, for its ink', () => { + expect(css.indexOf('.toast-info {')).toBeLessThan(css.indexOf('.security-notice {')); + for (const token of ['var(--bg-card)', 'var(--text)', 'var(--text-dim)', 'var(--yellow)', 'var(--red)']) { + expect(block, token).toContain(token); + } + expect(block).not.toMatch(/(? { + const rule = block.slice(0, block.indexOf('}')); + expect(rule).toMatch(/transition: opacity [^;]*, transform [^;]*;/); + expect(block).toMatch( + /@media \(prefers-reduced-motion: reduce\) \{\s*\.security-notice \{\s*transform: none;\s*transition: none;/ + ); + }); + + it('fills a phone width minus the stack gutters', () => { + expect(block).toMatch(/@media \(max-width: 599px\) \{\s*\.security-notice \{\s*width: calc\(100vw - 1rem\);/); + }); +}); From 54d0699abdc38ce0cdd187aa42fcce4f2acf5b5d Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:59:39 +0200 Subject: [PATCH 4/8] docs: how security notices work, and how to add and retire one src/web/CLAUDE.md gets the load-bearing rules (hand-maintained file, strictly-true availability, one surface per notice per load, seen only on a user action, no CLI-id branching, theme tokens only), and docs/releasing.md a section on adding a notice, its fields, and letting `expires` retire it. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/releasing.md | 28 ++++++++++++++++++++++++++++ src/web/CLAUDE.md | 2 ++ 2 files changed, 30 insertions(+) diff --git a/docs/releasing.md b/docs/releasing.md index b99092f4..fb1248b9 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -31,3 +31,31 @@ When user says "COM": 9. **Announce it in Discussions**: every release gets a post in the [Announcements](https://github.com/Ark0N/Codeman/discussions/categories/announcements) category, shaped like #418 and #302: the short version first (one bold lead-in per theme, features before fixes, what it does for the user rather than how it works), contributor @-mentions inline where their work is described (a mention notifies them, and a contributor reposting is the cheapest reach this project has), a link to the release, and the Thanks names at the end. Casual first-person voice, no em-dashes, humanizer pass when the skill is available. A same-day follow-on patch (1.28.1 after 1.28.0) is folded into the previous post as an edit, never a second thread. Post it with `gh api graphql -F body=@ -f title='Codeman : ' -f repo=R_kgDOQ-SMDg -f cat=DIC_kwDOQ-SMDs4DCHZE -f query='mutation($repo:ID!,$cat:ID!,$title:String!,$body:String!){createDiscussion(input:{repositoryId:$repo,categoryId:$cat,title:$title,body:$body}){discussion{number url}}}'` (the repo's node id and its Announcements category id; `pinDiscussion` does not exist in the API, so pinning stays a click in the UI). This step exists because announcements stopped at 1.18 (#302) while ten releases shipped with nobody notified; #418 is the backfill covering 1.19.0 to 1.28.1, and release notes only count as content once they reach a surface people are subscribed to. CI runs `npm run check:lockfile` on every push/PR, so lockfile drift fails the build even if the `version-packages` script is bypassed. + +## Security notices + +A security notice is a sticky warning card about something outside Codeman that its users should act on (the first: OpenCode's Console breach, October 2026). On a browser that gets the release's update toast it appears inside that toast instead, so nobody sees it twice. Notices live in `src/web/public/security-notices.json`, which is maintained BY HAND; never put one in `whats-new.json`, which `npm run version-packages` rewrites. A notice reaches users only with a release, so add it before the COM that should carry it. + +**Adding one**: append an entry to `notices`: + +```json +{ + "id": "opencode-console-breach-2026-10", + "severity": "warning", + "cli": "opencode", + "published": "2026-10-18", + "expires": "2026-12-31", + "title": "OpenCode Console breach: check your API keys", + "message": "One or two plain sentences: what happened and what to do.", + "link": { "label": "Read OpenCode's notice", "url": "https://..." }, + "i18n": { "zh-CN": { "title": "...", "message": "...", "label": "..." } } +} +``` + +- `id` is stable and unique, and never reused: a browser that closed a notice remembers its id (`codeman-security-notices-seen` in localStorage), so a reworded notice under the same id is NOT shown again to anyone who closed it. Give a materially new warning a new id. +- `cli` (optional) is a registry CLI id. The notice is then shown only on a server where that CLI is installed and enabled; without `cli` it is shown to everyone. +- `published` and `expires` are inclusive `YYYY-MM-DD` days in the viewer's local time. `expires` is what keeps an old notice from alarming a browser that first opens Codeman months later, so always set one. +- `title` (optional) is the card's bold line; keep it short. `link.url` must be https. `i18n` is optional; a missing string falls back to the English one. +- Run `npx prettier --write src/web/public/security-notices.json` and `npm test -- test/security-notices.test.ts`, which validates the file (unique ids, real dates, https links, every `cli` a stock registry id, a zh-CN title wherever there is an English one). + +**Retiring one**: let it expire, or delete the entry once it has. Removing it early stops it on the next release only; browsers already showing it keep the card until they close it or reload. diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index d7d9de77..b22a02cd 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -26,6 +26,8 @@ **Spreadsheet preview (XLSX)** (`spreadsheet-preview.js` + `spreadsheet-preview-worker.js`; moved from File-path links in the root file): ⚠️ **XLSX previews parse in the BROWSER**, never on the server: `spreadsheet-preview.js` fetches the raw route with `?preview=true` (413 above `MAX_XLSX_BROWSER_PREVIEW_BYTES`, 10 MB) and hands the bytes to `spreadsheet-preview-worker.js`, the only place the pinned `exceljs`/`fflate` vendor bundles load (never on page load). `admitXlsx()` caps the ZIP before ExcelJS runs, counting what ExcelJS will EXPAND as well as what it reads (a merge costs its area, a `` past 16384 is refused, validations are never parsed via `ignoreNodes`, and defined names are never expanded: the worker stubs `_definedNames.model`). ⚠️ The INDEX a row or sheet claims is bounded too, since ExcelJS allocates and walks up to it: a `` outside 1-1048576 and an `xl/workbook.xml` `` above `LIMITS.maxSheetId` (65535) are refused, and `sendTile` reuses the merges read at load (`mergesById`), never `sheet.model`, which rebuilds the whole sheet. ⚠️ The COLUMN index costs the same way (a row's cells live at `_cells[col - 1]`, so one `XFD` cell makes ExcelJS's `eachRow`/`eachCell`/`hasValues` visit 16,384 slots per row): `worksheetMetadata` builds each sheet's row and cell index from the keys that exist (`Object.keys` of `_rows` and `_cells`, skipping falsy and `Null`-type cells as `eachCell({ includeEmpty: false })` does), reads row heights in that pass and merges from `sheet._merges`, and `sendTile` reads cells from that index (`populatedRowsById`); never call ExcelJS's dense `eachRow`/`eachCell` there. `parseThemePalette` returns the default palette for a theme above 64 KB (64 * 1024 characters), since its patterns are quadratic on unclosed tags. ⚠️ Merges are capped per sheet (`LIMITS.maxMergesPerSheet`, 2,000) AND workbook-wide (`LIMITS.maxMerges`, 10,000), refused as `merge-limit` before ExcelJS loads, since ExcelJS's `_mergeCellsInternal` checks each merge against every earlier one on its sheet (quadratic). Every `` in `xl/styles.xml` is read with `readTagAttributes()` and refused (`number-format`) when its decoded `formatCode` is over 255 characters or has a `[` after its last `]`: ExcelJS's `isDateFmt` rescans to the end of the code per unclosed `[` once per numeric cell, and the code is echoed into the notice bar. ⚠️ `formatCellValue` caps every cell's display text at `LIMITS.maxCellTextChars` (1,000, ellipsis, never splitting a surrogate pair) for every value shape (rich text, hyperlink text, formula source, errors), since structured clone copies each tile cell's whole string to the page and the load timeout no longer covers tiles. `richTextPrefix` also visits at most `maxCellTextChars + 1` runs, not only keeps that much text: an empty `` adds nothing, so a shared string of a million empty runs was walked whole per cell, per tile (56 s a tile). ⚠️ Every part ExcelJS parses (all but the pure-bytes `xl/media/.`) is also charged per START TAG against `LIMITS.maxElements` (2,000,000, refused as `element-limit`): ExcelJS builds an object per element in sharedStrings, styles, comments, drawings, VML and tables too, and empty stored blocks pad a deflate stream past the ratio cap, so 8.3M empty `` runs in a 375 KB file cost 570 MB of heap. ⚠️ Admission keys every entry on the name ExcelJS will SEE (`excelJsEntryName()`: JSZip's `.`/`..`/empty-segment resolution, then one leading `/` stripped), refuses two entries that land on one name, and treats anything matching ExcelJS's UNANCHORED `xl/worksheets/sheet.xml` as a worksheet, so `/xl/...` or `xl/./...` cannot skip a counter, and ExcelJS parses only a STORE-only archive rebuilt from the entries admission inflated (`buildAdmittedArchive()`), never the fetched bytes; cell text goes through `textContent`, formulas are never evaluated. Bumping either package or editing the worker/core changes `SPREADSHEET_ASSET_VERSION`, which `npm run check:public-assets` pins. xls/ods stay download-only. +**Security notices** (`src/web/public/security-notices.json`; placed by `checkWhatsNew()` in settings-ui.js through the pure `CodemanSecurityNotices.select()`/`.plan()` in constants.js; drawn by `showSecurityNotice()`/`showUpdateNotice()` in panels-ui.js, CSS `.security-notice*` beside the toast rules): a designed warning card (shield, "Security notice" eyebrow, title, message, the notice's link, Dismiss, ×) about something outside Codeman its users should act on (the first: OpenCode's Console breach, Oct 2026). ⚠️ The file is HAND-maintained and ships with a release; never put a notice in `whats-new.json`, which `npm run version-packages` rewrites. Adding and retiring one: `docs/releasing.md`. ⚠️ A notice naming a `cli` shows ONLY when `window.__codemanCliAvailable[cli] === true`: absent, unknown and false all mean "not installed", the opposite of `isCliAvailable()` (which offers a launch on a doubt), because a warning about software you do not have is noise. The id lives only in the JSON as data; no code branches on it (`test/frontend-cli-no-id-branching.test.ts` scans constants.js). ⚠️ ONE notification per notice per load, decided only by `plan()`: when the once-per-release update toast shows (an existing install on a new version), every selected notice rides INSIDE it as a warning section beside "See what's new"; otherwise (a fresh install, a same-version reload) each gets its own card. With no notice selected the update toast is the plain `showToast` it always was, pinned byte for byte by the test. ⚠️ A notice is recorded as seen (`codeman-security-notices-seen`, a JSON array) ONLY on a user action (×, Dismiss, its link), never when it appears; "See what's new" opens the release notes and leaves an unanswered warning up. Storage blocked shows neither the notices nor the update toast. ⚠️ The cards sit in the SAME `.toast-container` (never their own layer), use theme tokens only (the light skins' `--yellow` is already a dark amber; the primary button's ink is `--bg-card`), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. The notice's own text is textContent with `data-i18n-skip` (it carries its own zh-CN); the card's own copy translates as usual. The server serves `.json` from `public/` as `no-cache`, never `immutable` (`test/static-cache-headers.test.ts`). Tests: `test/security-notices.test.ts`. + ## Frontend modules and UI subsystems Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `