fix(ui): notice buttons act once, links send no referrer, solo windows defer

- A closing notice card stays in the DOM for its 200 ms fade, and its
  link and "See what's new" buttons still worked there: a double click
  opened the notice twice. close() now makes the card inert and every
  button handler does nothing once it is closing.
- Notice links (and the combined card's "See what's new") open with
  noopener,noreferrer. noopener alone still sent this instance's origin,
  which can be a tailnet name or a tunnel URL, as the Referer. The plain
  update toast is unchanged.
- The https rule is checked again at the button
  (CodemanSecurityNotices.urlOk): a caller handing showSecurityNotice or
  showUpdateNotice an unchecked URL gets no link button.
- A detached session window (/session/:id) is rendered without
  __codemanCliAvailable, so it could never select the OpenCode notice,
  yet it took the update toast and recorded the version, and the
  dashboard then showed the notice as a separate card. While any notice
  is pending, a solo window now shows and records nothing and leaves
  both to the dashboard; with none pending it shows the plain toast as
  before.
- The wiring tests pin the page's own clock (JSDOM gives the window its
  own Date). On the real clock 18 of them would have failed on every push
  from 2027-01-01, when the fixture notice expires. New tests pin the
  once-only close directly (the old double-click comment claimed more
  than it checked), the single open, the sink check, the solo windows,
  the scroll cap and the light-skin ink.
- CLAUDE.md now says the public .json files are served no-cache, the
  exception to the year-long immutable cache.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 09:30:22 +02:00
1 parent ee6b115023
commit 82c34fba99
6 files changed
+184 -21

No files matched your search

+1 -1
View File
@@ -26,7 +26,7 @@
**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 notice is recorded as seen (`codeman-security-notices-seen`, a JSON array) ONLY on a user action (×, Dismiss, its link), never when it appears; "See what's new" opens the release notes and leaves an unanswered warning up. Storage blocked shows neither the notices nor the update toast. ⚠️ The cards sit in the SAME `.toast-container` (never their own layer), use theme tokens only (the light skins' `--yellow` is already a dark amber; the primary button's ink is `--bg-card`), animate opacity and transform only, and nothing moves under `prefers-reduced-motion`. The notice's own text is textContent with `data-i18n-skip` (it carries its own zh-CN); the card's own copy translates as usual. The server serves `.json` from `public/` as `no-cache`, never `immutable` (`test/static-cache-headers.test.ts`). Tests: `test/security-notices.test.ts`.
**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
+6 -1
View File
@@ -3583,5 +3583,10 @@ function planStartupNotices(state = {}) {
}
if (typeof window !== 'undefined') {
window.CodemanSecurityNotices = { select: selectSecurityNotices, plan: planStartupNotices };
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),
};
}
+30 -10
View File
@@ -6298,10 +6298,9 @@ Object.assign(CodemanApp.prototype, {
main.append(this._noticeEl('div', 'security-notice-eyebrow', 'Security notice'));
main.append(...this._noticeTextBlock(notice));
const actions = this._noticeEl('div', 'security-notice-actions');
actions.append(
this._noticeLinkButton(notice, () => card.close('link')),
this._noticeButton('security-notice-quiet', 'Dismiss', () => card.close('dismiss'))
);
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();
@@ -6336,10 +6335,15 @@ Object.assign(CodemanApp.prototype, {
head.append(this._noticeIcon('shield'), this._noticeEl('span', 'security-notice-eyebrow', 'Security notice'));
section.append(head, ...this._noticeTextBlock(notice));
main.append(section);
actions.append(this._noticeLinkButton(notice, () => { seen(notice.id); card.close('link'); }));
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", () => window.open(releaseUrl, '_blank', 'noopener'))
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);
@@ -6347,7 +6351,12 @@ Object.assign(CodemanApp.prototype, {
return { dismiss: () => card.close(null) };
},
/** The shell both notice cards share: role=alert, the ×, mount and a once-only close. */
/**
* 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');
@@ -6356,6 +6365,7 @@ Object.assign(CodemanApp.prototype, {
const close = (reason) => {
if (closed) return;
closed = true;
el.inert = true;
el.classList.remove('show');
setTimeout(() => el.remove(), 200);
if (reason && typeof onUserClose === 'function') {
@@ -6368,7 +6378,8 @@ Object.assign(CodemanApp.prototype, {
this._toastStack().appendChild(el);
requestAnimationFrame(() => el.classList.add('show'));
};
return { el, closeButton, close, mount };
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. */
@@ -6379,11 +6390,20 @@ Object.assign(CodemanApp.prototype, {
return parts;
},
_noticeLinkButton(notice, after) {
/**
* 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,
() => { window.open(notice.url, '_blank', 'noopener'); after(); },
card.act(() => { window.open(url, '_blank', 'noopener,noreferrer'); after(); }),
true
);
},
+17 -2
View File
@@ -386,6 +386,14 @@ Object.assign(CodemanApp.prototype, {
* 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) {
if (this._whatsNewChecked) return;
@@ -398,6 +406,7 @@ Object.assign(CodemanApp.prototype, {
seen && seen !== runningVersion ? this._fetchWhatsNewNotes() : null,
this._loadSecurityNotices(),
]);
if (this.isSoloWindow && notices.length) return;
const plan = window.CodemanSecurityNotices.plan({ seenVersion: seen, runningVersion, notes, notices });
if (plan.recordVersion) remember();
if (plan.update) {
@@ -443,7 +452,9 @@ Object.assign(CodemanApp.prototype, {
* 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).
* 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;
@@ -452,8 +463,12 @@ Object.assign(CodemanApp.prototype, {
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: window.__codemanCliAvailable,
available,
seen,
now: Date.now(),
lang: window.CodemanI18n?.language || window.__codemanLanguage,