Merge branch 'relnext/security-notices': security notices, starting with the OpenCode Console breach

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 10:03:02 +02:00
commit f744c503c5
12 files changed
+1801 -25

No files matched your search

+1 -1
View File
@@ -44,7 +44,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values 2. **Frontend changes**: Use Playwright to load the page and assert the UI renders correctly. Use `waitUntil: 'domcontentloaded'` (not `networkidle` — SSE keeps the connection open). Wait 3-4s for polling/async data to populate, then check element visibility, text content, and CSS values
3. **Only after verification passes**, proceed with COM 3. **Only after verification passes**, proceed with COM
The production server caches static files for 1 year, `immutable` (`maxAge: '1y'` in `server.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML. The production server caches static files for 1 year, `immutable` (`setHeaders` in `server.ts`), except the `.json` data files under `public/` (`whats-new.json`, `security-notices.json`, `manifest.json`), which are served `no-cache` because they are neither content-hashed nor `?v=`-busted (pinned by `test/static-cache-headers.test.ts`). To avoid stale frontend after a deploy, `renderIndexHtml` runs `cacheBustAssets(html)` — it appends `?v=<mtime>` to **every same-origin `.js`/`.css`** reference (mtime memoized ~1s so a burst of renders is cheap; external/already-versioned/missing refs untouched). Because `index.html` is served `no-cache`, a **normal reload now picks up edited modules/styles — no hard refresh needed** (the gesture bundle is injected separately with its own `?v=`). If you add an asset referenced by an _absolute_ URL or from JS rather than a `<script>/<link>` tag, it won't be auto-busted. ⚠️ **`index.html` itself is the exception: it is read ONCE into `indexHtmlTemplate` in the `WebServer` constructor**, so editing markup in dev needs a server restart (edited `.js`/`.css` do not) — otherwise you debug a "CSS class that doesn't apply" that is really an element still missing from the served HTML.
## COM Shorthand (Deployment) ## COM Shorthand (Deployment)
+29
View File
@@ -31,3 +31,32 @@ 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=@<file> -f title='Codeman <x.y.z>: <hook>' -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. 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=@<file> -f title='Codeman <x.y.z>: <hook>' -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. 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-17",
"expires": "2026-12-31",
"title": "OpenCode Console breach: check your API keys",
"message": "A few short plain sentences, about 50 words at most: 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. Set `published` a day before the release ships, so the notice is already live in every timezone when it lands (a browser still on the previous day would otherwise take the plain update toast and see the notice later as a separate card). `expires` is what keeps an old notice from alarming a browser that first opens Codeman months later, so always set one.
- Keep `message` to about 50 words and do not repeat the card's chrome (the "Security notice" eyebrow and the title are already on it): on a phone the card shares the screen with the update toast's text, and a longer one has to be scrolled to reach its buttons. State only what the affected vendor has said.
- `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.
+2
View File
@@ -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 `<col>` 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 `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` 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 `<numFmt>` 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 `<r/>` 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/<name>.<ext>`) 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 `<r/>` 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<N>.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. **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 `<col>` 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 `<row r>` outside 1-1048576 and an `xl/workbook.xml` `<sheet sheetId>` 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 `<numFmt>` 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 `<r/>` 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/<name>.<ext>`) 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 `<r/>` 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<N>.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 detached session window (`/session/:id`) is rendered WITHOUT `__codemanCliAvailable`, so it cannot select a CLI notice: while any notice is pending (every CLI the file names counted as installed), it shows and records nothing and leaves the update toast and the notice to the dashboard, or it would record the version and the dashboard would show the notice as a separate card; with none pending it shows the plain toast as before. ⚠️ 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 (small amber text and the primary button use `--notice-ink`, which the light skins darken a step because their `--yellow` is under 4.5:1 for that text on white; the primary button's label is `--bg-card`), are capped to the room below the stack's top and scroll inside themselves (the container is fixed and cannot scroll, so the accent bar is a background layer, never a positioned child), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. A closing card is inert and its buttons do nothing during the fade; links open with `noopener,noreferrer` and the https rule is checked again at the button (`CodemanSecurityNotices.urlOk`). 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 modules and UI subsystems
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order is the `<script>` order in `index.html` (each module's `@loadorder` tag mirrors it). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
+154
View File
@@ -3436,3 +3436,157 @@ if (typeof window !== 'undefined') {
TERMINAL_MIN_ROWS, 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<object>} notices - `notices` from security-notices.json
* @param {{available?: Record<string, boolean>, seen?: Iterable<string>, 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<object>}} state
* `notices` is the output of selectSecurityNotices().
* @returns {{recordVersion: boolean, update: boolean, updateNotices: Array<object>, cards: Array<object>}}
*/
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,
// The same https-only rule select() applies, for the card's link button to check again.
urlOk: (url) => typeof url === 'string' && SECURITY_NOTICE_URL_PATTERN.test(url),
};
}
+3
View File
@@ -848,6 +848,9 @@
'Settings saved': '设置已保存', 'Settings saved': '设置已保存',
// The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English. // The once-per-release toast (settings-ui.js checkWhatsNew); its highlights stay English.
"See what's new": '查看更新内容', "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': '设置已应用', 'Settings applied': '设置已应用',
'Save and keep Settings open': '保存并保持设置打开', 'Save and keep Settings open': '保存并保持设置打开',
'Settings saved locally': '设置已保存到本机', 'Settings saved locally': '设置已保存到本机',
+184 -10
View File
@@ -6249,16 +6249,7 @@ Object.assign(CodemanApp.prototype, {
closeBtn.onclick = (e) => { e.stopPropagation(); dismiss(); }; closeBtn.onclick = (e) => { e.stopPropagation(); dismiss(); };
toast.appendChild(closeBtn); toast.appendChild(closeBtn);
// Cache toast container reference this._toastStack().appendChild(toast);
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);
requestAnimationFrame(() => toast.classList.add('show')); requestAnimationFrame(() => toast.classList.add('show'));
@@ -6272,6 +6263,189 @@ Object.assign(CodemanApp.prototype, {
return { dismiss, setMessage: (text) => { msgSpan.textContent = text; } }; 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');
const link = this._noticeLinkButton(notice, card, () => card.close('link'));
if (link) actions.append(link);
actions.append(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<object>, 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);
const link = this._noticeLinkButton(notice, card, () => { seen(notice.id); card.close('link'); });
if (link) actions.append(link);
}
actions.append(
this._noticeButton(
'security-notice-secondary',
"See what's new",
card.act(() => window.open(releaseUrl, '_blank', 'noopener,noreferrer'))
)
);
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.
* A closing card stays in the DOM for its 200 ms fade, so close() also makes it inert, and
* `act(fn)` wraps a button's handler to do nothing once the card is closing (a double click
* on a link used to open it twice; the × and Dismiss go through close(), already once-only).
*/
_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.inert = 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'));
};
const act = (fn) => () => { if (!closed) fn(); };
return { el, closeButton, close, mount, act };
},
/** 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;
},
/**
* The notice's link as the primary button, or null when the link is not https.
* selectSecurityNotices() already drops such a notice; the URL is checked again here, at the
* sink, because showSecurityNotice/showUpdateNotice take any object. Opened with noopener
* AND noreferrer: noopener alone still sends this instance's origin (a tailnet name, a tunnel
* URL) to the linked site as the Referer.
*/
_noticeLinkButton(notice, card, after) {
const url = notice.url;
if (!window.CodemanSecurityNotices?.urlOk?.(url)) return null;
return this._noticeButton(
'security-notice-primary',
notice.label,
card.act(() => { window.open(url, '_blank', 'noopener,noreferrer'); 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 * 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 * genuinely worth interrupting the eye for rather than living in the corner with every
+21
View File
@@ -0,0 +1,21 @@
{
"notices": [
{
"id": "opencode-console-breach-2026-10",
"severity": "warning",
"cli": "opencode",
"published": "2026-10-17",
"expires": "2026-12-31",
"title": "OpenCode Console breach: check your API keys",
"message": "OpenCode reported unauthorized access to a legacy system on 17 October. Provider API keys (OpenAI, Anthropic and others) saved in some OpenCode Console workspaces may have been exposed. If OpenCode emailed you about it, revoke and replace those keys and check their recent usage. If you got no email, OpenCode says you were not affected.",
"link": { "label": "Read OpenCode's notice", "url": "https://x.com/opencode/status/2111519637049319818" },
"i18n": {
"zh-CN": {
"title": "OpenCode Console 遭入侵:请检查你的 API 密钥",
"message": "OpenCode 于 10 月 17 日报告,其一个旧系统遭到未授权访问,部分用户保存在 OpenCode Console 工作区中的提供商 API 密钥(OpenAI、Anthropic 等)可能已泄露。如果你收到了 OpenCode 的相关邮件,请撤销并更换这些密钥,同时检查近期用量。OpenCode 表示,没有收到邮件的用户不受影响。",
"label": "查看 OpenCode 公告"
}
}
}
]
}
+103 -12
View File
@@ -378,32 +378,123 @@ Object.assign(CodemanApp.prototype, {
* The notes come from /whats-new.json, which `npm run version-packages` regenerates from * 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 * 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". * 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.
*
* ⚠️ A detached session window (/session/:id) is rendered without __codemanCliAvailable, so
* it can never select a notice naming a CLI. Were it to take the update toast anyway, it
* would record the version and the dashboard would later show that notice as a separate
* card, not inside the update toast. So while any notice is pending (_loadSecurityNotices
* counts every CLI the file names as installed there), a solo window shows and records
* nothing and leaves both to the dashboard; with none pending it shows the plain update
* toast as it always did.
*/ */
async checkWhatsNew(runningVersion) { async checkWhatsNew(runningVersion) {
if (this._whatsNewChecked) return; if (this._whatsNewChecked) return;
this._whatsNewChecked = true; this._whatsNewChecked = true;
const KEY = 'codeman-whats-new-seen'; const KEY = 'codeman-whats-new-seen';
let seen = null; let seen = null;
try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement */ return; } try { seen = localStorage.getItem(KEY); } catch { /* storage blocked: skip the announcement and the notices */ return; }
if (seen === runningVersion) return;
const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } }; const remember = () => { try { localStorage.setItem(KEY, runningVersion); } catch { /* ignore */ } };
if (!seen) { remember(); return; } const [notes, notices] = await Promise.all([
try { seen && seen !== runningVersion ? this._fetchWhatsNewNotes() : null,
const res = await fetch('/whats-new.json', { cache: 'no-cache' }); this._loadSecurityNotices(),
if (!res.ok) return; ]);
const notes = await res.json(); if (this.isSoloWindow && notices.length) return;
if (notes?.version !== runningVersion || !Array.isArray(notes.highlights) || !notes.highlights.length) return; 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 shown = notes.highlights.slice(0, 3).join(' · ');
const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : ''; const more = notes.highlights.length > 3 ? ` (+${notes.highlights.length - 3} more)` : '';
remember(); const text = `Codeman updated to v${runningVersion}: ${shown}${more}`;
this.showToast(`Codeman updated to v${runningVersion}: ${shown}${more}`, 'info', { 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, duration: 20000,
action: { action: {
label: "See what's new", label: "See what's new",
onClick: () => window.open(`https://github.com/Ark0N/Codeman/releases/tag/codeman%40${runningVersion}`, '_blank', 'noopener'), onClick: () => window.open(releaseUrl, '_blank', 'noopener'),
}, },
}); });
} catch { /* offline or malformed: try again next load */ } }
}
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). In a solo window every CLI the file
* names counts as installed: there the answer only decides whether to leave the release to
* the dashboard (see checkWhatsNew), and is never shown.
*/
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();
const list = Array.isArray(data?.notices) ? data.notices : [];
const available = this.isSoloWindow
? Object.fromEntries(list.filter((entry) => typeof entry?.cli === 'string').map((entry) => [entry.cli, true]))
: window.__codemanCliAvailable;
return window.CodemanSecurityNotices.select(data?.notices, {
available,
seen,
now: Date.now(),
lang: window.CodemanI18n?.language || window.__codemanLanguage,
});
} 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() { openAppSettings() {
+286
View File
@@ -9742,6 +9742,292 @@ kbd {
.toast-warning { border-color: rgba(234, 179, 8, 0.4); } .toast-warning { border-color: rgba(234, 179, 8, 0.4); }
.toast-info { border-color: rgba(59, 130, 246, 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. Small amber text and the primary button use --notice-ink: --yellow on the dark
skins, and on the light skins --yellow darkened a step, because their --yellow alone is
under 4.5:1 for 11-12px text on white (3.9 to 4.4 measured). The primary button's label is
--bg-card (dark on the dark skins' light amber, white on the light skins' dark amber). The
entrance animates opacity and transform only, and nothing moves under
prefers-reduced-motion.
The card is capped to the room below the stack's top and scrolls inside itself: the
container is fixed and does not scroll, so on a short screen (a phone in landscape) the
buttons at the bottom were otherwise off-screen and unreachable. The accent bar down the
left edge (amber running into red) is therefore a background layer, not a positioned
child: a background stays put while the content scrolls. */
.security-notice {
--notice-ink: var(--yellow);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
column-gap: 0.75rem;
align-items: start;
box-sizing: border-box;
width: min(420px, calc(100vw - 1rem));
max-height: calc(var(--app-height, 100dvh) - var(--header-height) - 1rem);
padding: 0.85rem 0.55rem 0.9rem 1.05rem;
background:
linear-gradient(180deg, var(--yellow), var(--red)) left top / 4px 100% no-repeat,
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-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
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;
}
html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="catppuccin-latte"], [data-skin="rose-pine-dawn"]) .security-notice {
--notice-ink: color-mix(in srgb, var(--yellow) 85%, black);
}
/* The update toast carrying a notice is still an update: its frame takes the accent colour,
and the amber is left to the warning section inside it. */
.security-notice.update-notice {
background:
linear-gradient(var(--accent), var(--accent)) left top / 4px 100% no-repeat,
var(--bg-card);
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
}
.security-notice-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
color: var(--notice-ink);
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(--notice-ink);
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;
text-wrap: balance;
}
/* Chinese has no spaces, so a balanced title would break inside a word (遭入 / 侵).
Break only at the spaces and punctuation the title has; overflow-wrap above still
rescues a run that cannot fit. */
:lang(zh) .security-notice-title {
word-break: keep-all;
}
.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(--notice-ink);
}
.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(--notice-ink);
color: var(--bg-card);
}
.security-notice-primary:hover {
background: color-mix(in srgb, var(--notice-ink) 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-dim);
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 every button grows to fill
its row (so one that wraps fills its own instead of sitting stray at the left). On a touch
pointer, below, buttons are at least --touch-target-min tall and the × gets a touch-sized
box, its negative margin keeping the glyph where it sits on a desktop. */
@media (max-width: 599px) {
.security-notice {
width: calc(100vw - 1rem);
}
.security-notice-actions > button {
flex: 1 1 auto;
}
}
/* Touch sizing follows the pointer, not the width: a phone turned to landscape is wider
than 599px and still needs fingertip-sized targets. */
@media (pointer: coarse) {
.security-notice-actions > button {
min-height: var(--touch-target-min);
}
.security-notice-close {
width: var(--touch-target-min);
height: var(--touch-target-min);
margin: -0.75rem -0.5rem 0 0;
}
}
@media (prefers-reduced-motion: reduce) {
.security-notice {
transform: none;
transition: none;
}
}
/* Notification Bell Button */ /* Notification Bell Button */
.btn-icon-header.btn-notifications { .btn-icon-header.btn-notifications {
position: relative; position: relative;
+10 -1
View File
@@ -964,7 +964,7 @@ export class WebServer extends EventEmitter {
// `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and // `ServerResponse` to a `FastifyReply`, so it is `reply.header()` here and
// NOT `res.setHeader()`. A v9-style body throws TypeError on every static // 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. // 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 // ⚠️ That same change ALSO flipped precedence, and silently. Under v9 this
// callback wrote to the raw response and Fastify's staged reply headers then // 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() // 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. // no-store` its route asks for — a service worker that can never update.
// So: a route that already decided keeps its answer. // So: a route that already decided keeps its answer.
if (reply.getHeader('Cache-Control') !== undefined) return; 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'); reply.header('Cache-Control', 'public, max-age=31536000, immutable');
}, },
}); });
+995
View File
@@ -0,0 +1,995 @@
/**
* @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<string, unknown>;
type Shown = { id: string; title: string; message: string; label: string; url: string };
type SelectOpts = { available?: Record<string, unknown>; seen?: Iterable<string>; 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<string, { title?: unknown; message?: unknown; label?: unknown }>;
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 =
'<div class="toast toast-info"><span class="toast-message">Codeman updated to v1.43.0: Alpha · Beta · Gamma (+1 more)</span>' +
'<button style="margin-left: 12px; padding: 2px 10px; background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 3px; color: inherit; cursor: pointer; font-size: 12px;">See what\'s new</button>' +
'<button class="toast-close" aria-label="Dismiss">×</button></div>';
type Reply = { ok: boolean; body?: unknown } | 'throw';
type WiredApp = {
isSoloWindow?: boolean;
checkWhatsNew(version: string): Promise<void>;
showSecurityNotice(notice: Shown, opts?: { onClose?: (reason: string) => void }): { dismiss(): void };
showUpdateNotice(opts: { text: string; releaseUrl: string; notices: Shown[]; onNoticeSeen?: (id: 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<string, unknown> | 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;
/** A detached session window (/session/:id), rendered without __codemanCliAvailable. */
solo?: 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('<!doctype html><html><head></head><body></body></html>', {
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);
// The page's own clock, pinned inside the fixtures' date window: JSDOM gives the window its
// own Date (vi.setSystemTime never reaches it), and on the real clock every test that expects
// a card would fail once the fixture's notice expired (2027-01-01).
win.eval(`Date.now = () => ${NOW};`);
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;
if (opts.solo) app.isSoloWindow = true;
const doc = win.document;
const cards = () => Array.from(doc.querySelectorAll<HTMLElement>('.security-notice:not(.update-notice)'));
const updateCards = () => Array.from(doc.querySelectorAll<HTMLElement>('.security-notice.update-notice'));
const plainToasts = () => Array.from(doc.querySelectorAll<HTMLElement>('.toast'));
const button = (root: Element, cls: string) => root.querySelector<HTMLButtonElement>(`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: '<b>bold</b>',
message: '<img src=x onerror="alert(1)">',
link: { label: '<i>Go</i>', 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('<img src=x onerror="alert(1)">');
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
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('reports a close once, however many times the card is clicked while it fades', async () => {
for (const cls of ['security-notice-close', 'security-notice-quiet', 'security-notice-primary']) {
const { app, cards, button, opened } = boot();
const onClose = vi.fn();
app.showSecurityNotice(
{ id: 'x', title: '', message: 'm', label: 'Go', url: 'https://example.com/' },
{ onClose }
);
const card = cards()[0];
button(card, cls).click();
button(card, cls).click();
button(card, 'security-notice-close').click();
expect(onClose, cls).toHaveBeenCalledTimes(1);
// A double click on the link opens it once, not twice.
expect(opened, cls).toHaveLength(cls === 'security-notice-primary' ? 1 : 0);
}
});
it('draws no link button for a link that is not https, whoever calls it', async () => {
const { app, cards, opened } = boot();
const onClose = vi.fn();
app.showSecurityNotice({ id: 'x', title: '', message: 'm', label: 'Go', url: 'javascript:alert(1)' }, { onClose });
const card = cards()[0];
expect(card.querySelector('.security-notice-primary')).toBeNull();
expect(Array.from(card.querySelectorAll('.security-notice-actions button')).map((b) => b.textContent)).toEqual([
'Dismiss',
]);
expect(opened).toEqual([]);
});
it('opens the link with noopener and noreferrer, 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,noreferrer']]);
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();
button(updateCards()[0], 'security-notice-primary').click(); // a double click while it fades
expect(opened).toEqual([['https://example.com/notice', '_blank', 'noopener,noreferrer']]);
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<HTMLButtonElement>('.security-notice-primary')[1].click();
expect(opened).toEqual([['https://example.com/2', '_blank', 'noopener,noreferrer']]);
// 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,noreferrer']]);
expect(seenIds()).toBeNull();
await afterClose();
expect(updateCards()).toHaveLength(1);
});
it('reports each notice once, and opens nothing, once the card is closing', async () => {
const { app, updateCards, button, opened } = boot();
const onNoticeSeen = vi.fn();
const one = { id: 'x', title: '', message: 'm', label: 'Go', url: 'https://example.com/' };
app.showUpdateNotice({ text: 'Updated', releaseUrl: RELEASE_URL, notices: [one], onNoticeSeen });
const card = updateCards()[0];
button(card, 'security-notice-close').click();
button(card, 'security-notice-close').click();
button(card, 'security-notice-primary').click();
button(card, 'security-notice-secondary').click();
expect(onNoticeSeen.mock.calls).toEqual([['x']]);
expect(opened).toEqual([]);
});
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<HTMLButtonElement>('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('a detached session window (/session/:id), rendered without CLI availability', () => {
it('leaves a pending notice and the update toast to the dashboard, recording nothing', async () => {
for (const seenVersion of ['1.42.0', RUNNING, undefined]) {
const {
app,
cards,
updateCards,
plainToasts,
seenIds,
seenVersion: version,
} = boot({
solo: true,
available: null,
seenVersion,
});
await app.checkWhatsNew(RUNNING);
expect([...cards(), ...updateCards(), ...plainToasts()], String(seenVersion)).toEqual([]);
expect(version()).toBe(seenVersion ?? null);
expect(seenIds()).toBeNull();
}
});
it('then lets the dashboard carry the notice inside its update toast', async () => {
const { win, app, updateCards, cards } = boot({ solo: true, available: null, seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
// The dashboard's load: same browser storage, availability injected.
const dashboard = Object.create(Object.getPrototypeOf(app)) as WiredApp;
win.__codemanCliAvailable = { somecli: true };
await dashboard.checkWhatsNew(RUNNING);
expect(cards()).toEqual([]);
expect(updateCards()).toHaveLength(1);
});
it('shows the plain update toast as before when no notice is pending', async () => {
for (const variant of [{ seenIds: ['cli-notice'] }, { notices: { ok: false } as Reply }] as BootOpts[]) {
const { app, plainToasts, cards, seenVersion } = boot({
solo: true,
available: null,
seenVersion: '1.42.0',
...variant,
});
await app.checkWhatsNew(RUNNING);
expect(
plainToasts().map((t) => t.outerHTML),
JSON.stringify(variant)
).toEqual([PLAIN_UPDATE_TOAST]);
expect(cards()).toEqual([]);
expect(seenVersion()).toBe(RUNNING);
}
});
});
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(/(?<!rgba\()#[0-9a-f]{3,8}\b/i);
});
it('animates opacity and transform only, and nothing under prefers-reduced-motion', () => {
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\);/);
});
it('caps its height and scrolls inside itself, since the fixed stack cannot scroll', () => {
const rule = block.slice(0, block.indexOf('}'));
expect(rule).toContain('max-height: calc(var(--app-height, 100dvh) - var(--header-height) - 1rem);');
expect(rule).toContain('overflow-y: auto;');
// A positioned accent bar would scroll away with the content.
expect(block).not.toContain('.security-notice::before');
});
it('darkens the amber ink on the light skins only', () => {
expect(block).toContain('--notice-ink: var(--yellow);');
expect(block).toMatch(
/html:is\(\[data-skin="paper-gray"\], \[data-skin="solarized-light"\], \[data-skin="catppuccin-latte"\], \[data-skin="rose-pine-dawn"\]\) \.security-notice \{\s*--notice-ink: color-mix\(in srgb, var\(--yellow\) 85%, black\);/
);
});
});
+13 -1
View File
@@ -11,7 +11,8 @@
* *
* That contract is load-bearing: assets are served `immutable` for a year, and * 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 * `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. * 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 * 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'); 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 () => { 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 // 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 // request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would