Skip to content

OpenConceptLab/ocl_online#425 | The announcement banner shows the deployment's ANNOUNCEMENT setting - #61

Merged
paynejd merged 4 commits into
masterfrom
ocl_online-425-announcement-setting
Oct 30, 2026
Merged

paynejd merged 4 commits into
masterfrom
ocl_online-425-announcement-setting

Conversation

@paynejd

@paynejd paynejd commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

The announcement banner above the app now shows only what the deployment sets in an optional ANNOUNCEMENT environment variable. When it's unset, no banner shows, so each deployment runs its own banner or none. Same change in TBv3 (OpenConceptLab/oclweb3#88) and the Mapper (OpenConceptLab/oclmap#121).

What changed

  • start-prod.sh writes ANNOUNCEMENT into env-config.js when the container starts, as an escaped JavaScript string, so a value can't end the string or add script. It escapes one character at a time, which behaves the same in every bash (${s//…} replacement handling changed in bash 5.2).
  • src/common/announcement.js (new, the same file in all three apps) parses and checks the setting. Text and labels are plain text; links must be https:; the user's language falls back to en; the banner hides after until with no restart; a dismissal is remembered by id. Invalid JSON, a missing field or a link that isn't https: means no banner.
  • AnnouncementBanner only displays what that returns. It keeps the layout (--announcement-height, the Header offset), and long text now wraps instead of running off a narrow screen. The close button's label is now common.close.
  • Removed: the announcement written into the app, its link constants and its announcement.* locale strings.
  • README: a short "Announcement banner" section with the JSON shape. docker-compose.yml passes ANNOUNCEMENT through.
{
  "id": "maintenance-2026-11",
  "text": {"en": "Planned maintenance on Saturday, 02:00-04:00 UTC.", "es": "Mantenimiento programado el sábado, de 02:00 a 04:00 UTC."},
  "links": [{"label": {"en": "Details", "es": "Detalles"}, "url": "https://status.example.org/"}],
  "until": "2026-11-09T00:00:00Z"
}

Tests

  • src/common/__tests__/announcement.test.js (19 tests): unset; valid; invalid JSON and JSON that isn't an object; missing or malformed fields; links that aren't https: (http:, javascript:, data:, relative); expired and malformed until, and the re-check that hides a banner at until; other languages and the en fallback; dismissal, including blocked storage; and start-prod.sh's escaping (values read back unchanged, and attempts to end the string don't run).
  • CI here runs ESLint only, on Node 14: ./node_modules/eslint/bin/eslint.js --ext .jsx,.js src/ is clean. The new test file uses node:test, which Node 14 doesn't have, so it runs locally, not in CI: node --test src/common/__tests__/announcement.test.js on Node 22 (19 pass).
  • Built this branch's production image and ran it: unset, no banner; set, that deployment's text and links. The built bundle has none of the old announcement's copy or links. At 375px wide, a long label wraps and the close button stays in reach. Dismissal, an invalid value carrying a script attempt, and expiry on an open page were checked in TBv3's image, which runs the same announcement.js and start-prod.sh code.

Review

Codex (codex-cli 0.166.0, reasoning xhigh), up to 3 passes:

  • Pass 1: a banner stayed on a page left open past until (P1). Fixed: the banner checks again when until arrives. links is now required too, from the oclweb3 review.
  • Pass 2: clean.
  • A simplification review cut five things: a redundant guard, a longer links check, unused defaults, a long assertion in the language test and a long comment. It kept the separate injection test, which names the requirement it pins.
  • Final Codex check of that commit: clean.
  • An adversarial review tried to break the escaping and the checks. No input added script: it ran 7,462 values through js_string in bash 3.2, 5.1 (the image's), 5.2 and 5.3. It found two things, both fixed: a long label pushed the close button off a phone screen (now the text wraps and the button keeps its size), and a timer delayed by a sleeping laptop could keep an ended banner up (now until is checked at least once a minute). Codex check of that commit: clean.
  • Each Codex pass is posted below.

Deploying

After this release, the banner shows only where a deployment sets ANNOUNCEMENT. Where it's unset, the app shows none.

Refs OpenConceptLab/ocl_online#425

🤖 Generated with Claude Code

https://claude.ai/code/session_01AaZ4qVYj2Kwzt3oKZf1AwU

paynejd and others added 4 commits October 29, 2026 20:05
…ent's ANNOUNCEMENT setting

The banner's text and links come from an optional ANNOUNCEMENT environment
variable (JSON), which start-prod.sh writes into env-config.js as an escaped
string when the container starts. The app parses and checks it: plain text,
https links only, the user's language with en as the fallback, hidden after
`until`, and dismissed by `id`. Unset or invalid, no banner shows.

Removes the announcement written into the app: its copy, link constants and
locale strings. README documents the setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaZ4qVYj2Kwzt3oKZf1AwU
…ge left open, and links is required

The banner checks the setting again when `until` arrives, so it goes away
without a reload. `links` is required, as in the setting's shape; `[]` means
no links.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaZ4qVYj2Kwzt3oKZf1AwU
…hecks

From the simplification review: JSON.parse's catch covers an unset or empty
setting, the links check maps and tests in one pass, the unused `now`
defaults go, and the language test and the component comment are shorter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaZ4qVYj2Kwzt3oKZf1AwU
…banner goes within a minute

From the adversarial review: a long label (a pasted URL, say) no longer
pushes the close button off a phone screen, because the text wraps anywhere
and the button keeps its size. The banner checks `until` at least once a
minute, so a timer that a sleeping laptop or a clock change delays can't keep
it up. README: `until` needs Z or an offset, and the npm start example works
with an apostrophe in the text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AaZ4qVYj2Kwzt3oKZf1AwU
@paynejd

paynejd commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Codex review, pass 1 (codex-cli 0.166.0, commit 65370ae)

Verdict: findings (1).

  • F1.1 [P1] src/components/app/AnnouncementBanner.jsx:27: The banner does not automatically disappear when its expiry deadline passes.

Response

findings.json
{
  "reviewer": "codex-cli 0.166.0",
  "perspective": "defects",
  "mode": "full",
  "commit": "65370ae64ea749e883b435f36df1637872cea84c",
  "verdict": "findings",
  "prior": [],
  "findings": [
    {
      "id": "F1.1",
      "severity": "P1",
      "tag": null,
      "file": "src/components/app/AnnouncementBanner.jsx",
      "line": 27,
      "claim": "The banner does not automatically disappear when its expiry deadline passes.",
      "scenario": "A user opens the app before `until` and stays on the same screen past that time. Expiry is checked only during render, and no timer triggers another render, so the expired banner and its header offset remain until an unrelated update or reload. The expiry test calls the parser with different timestamps and misses this behavior.",
      "action": "Schedule a state update at expiry, with timer cleanup, and test that advancing time while the component remains mounted removes both the banner and its height offset.",
      "lines_delta": null,
      "confidence": "high"
    }
  ]
}

@paynejd

paynejd commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Codex review, pass 2 (codex-cli 0.166.0, commit f624636)

Verdict: clean. F1.1 resolved.

findings.json
{
  "reviewer": "codex-cli 0.166.0",
  "perspective": "defects",
  "mode": "verify",
  "commit": "f624636b6525b40049d34780a763b5b68d216bdc",
  "verdict": "clean",
  "prior": [
    {
      "id": "F1.1",
      "status": "resolved"
    }
  ],
  "findings": []
}

@paynejd

paynejd commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Simplification review (claude subagent, commit c55b3f9c in oclweb3; it covered all three repos)

  • S1 (delete): applied: JSON.parse's catch covers an unset or empty setting.
  • S2 (shrink): applied: the links check is a map plus some.
  • S3 (yagni): applied: the now defaults and the ?. in msUntilExpiry are gone; the F1.1 tests stay.
  • S4 (dup-test): kept: the separate test names the requirement it pins (a setting can't add script to env-config.js), and merging saves only 8 lines.
  • S5 (dup-test): applied: the Spanish case checks only the translated text and labels.
  • S6 (shrink): applied: the component comment is two lines.

Applied in 94d8d3b. Tests rerun: green.

simplify.json
{
  "reviewer": "claude subagent",
  "perspective": "simplify",
  "mode": "full",
  "commit": "c55b3f9cbf1323306a64a7455f3e3499d5d83262",
  "verdict": "findings",
  "prior": [
    {
      "id": "F1.1",
      "status": "resolved"
    },
    {
      "id": "F1.2",
      "status": "resolved"
    }
  ],
  "findings": [
    {
      "id": "S1",
      "severity": null,
      "tag": "delete",
      "file": "oclweb3/src/common/announcement.js",
      "line": 37,
      "claim": "All three copies: the opening guard `if(typeof raw !== 'string' || !raw.trim()) return null` is redundant. The JSON.parse try/catch right below it already returns null for undefined, '', whitespace and any non-JSON value (JSON.parse coerces them and throws). isObject then rejects null, numbers and other non-object JSON. The validation stays; only the duplicate check goes.",
      "scenario": null,
      "action": "Nothing replaces it. I checked this on a scratch copy: the 'no setting: no banner' test and the other 17 still pass.",
      "lines_delta": -2,
      "confidence": "high"
    },
    {
      "id": "S2",
      "severity": null,
      "tag": "shrink",
      "file": "oclweb3/src/common/announcement.js",
      "line": 52,
      "claim": "All three copies: the links loop runs isObject(link) twice and builds the array by hand. localize already returns null for a label that isn't an object, and httpsURL returns null for a missing url, so `link?.` covers links that are strings, null or arrays. The file already uses `?.` (language?.split, announcement?.until), so this is safe for oclweb2's ESLint 4 + babel-eslint.",
      "scenario": null,
      "action": "Replace the 8-line loop with:\n  const links = setting.links.map(link => ({label: localize(link?.label, language), url: httpsURL(link?.url)}))\n  if(links.some(link => !link.label || !link.url))\n    return null\nAll link tests pass on a scratch copy.",
      "lines_delta": -5,
      "confidence": "medium"
    },
    {
      "id": "S3",
      "severity": null,
      "tag": "yagni",
      "file": "oclweb3/src/common/announcement.js",
      "line": 36,
      "claim": "All three copies: the `now = Date.now()` defaults on getAnnouncement and msUntilExpiry, and the `?.` on `announcement` in msUntilExpiry, guard cases no caller produces. AnnouncementBanner is the only caller. It always passes now and calls msUntilExpiry only when `open`, which means announcement is not null. Removing the `?.` also means removing the one-line `assert.equal(msUntilExpiry(null), null)` (test line 97). The assertions that pin F1.1 stay untouched: the one-hour delay, null for no until, and the re-check loop test.",
      "scenario": null,
      "action": "Use `(raw, language, now)` and `(announcement, now) => announcement.until ? ... : null`, and delete the msUntilExpiry(null) assertion. 18/18 pass on a scratch copy.",
      "lines_delta": -1,
      "confidence": "low"
    },
    {
      "id": "S4",
      "severity": null,
      "tag": "dup-test",
      "file": "oclweb3/src/common/__tests__/announcement.test.js",
      "line": 168,
      "claim": "All three copies: 'a setting cannot end the string and run script in env-config.js' repeats the checks of the round-trip test above it. That test already asserts Object.keys(context) is exactly ['ANNOUNCEMENT'] and the value reads back unchanged, and any payload that ends the string fails both checks. Merging keeps the same injection coverage with all four payloads.",
      "scenario": null,
      "action": "Move its four payloads into the round-trip test's value list and delete the separate test. On a scratch copy I removed each of js_string's four escape cases (\\\\, \\\", \\n, \\r) one at a time, and the merged test still failed every time.",
      "lines_delta": -8,
      "confidence": "medium"
    },
    {
      "id": "S5",
      "severity": null,
      "tag": "dup-test",
      "file": "oclweb3/src/common/__tests__/announcement.test.js",
      "line": 118,
      "claim": "All three copies: in the language test, the 9-line deepEqual for 'es' re-pins id, until and both URLs, which 'a valid setting shows its text and links' already covers. The only new behaviour it checks is the translated text and labels, including the en fallback for the second link's label.",
      "scenario": null,
      "action": "Replace it with:\n  assert.equal(announce(SETTING, 'es').text, 'Saturday maintenance, in Spanish.')\n  assert.deepEqual(announce(SETTING, 'es').links.map(link => link.label), ['Details, in Spanish', 'Contact us'])",
      "lines_delta": -7,
      "confidence": "medium"
    },
    {
      "id": "S6",
      "severity": null,
      "tag": "shrink",
      "file": "oclweb3/src/components/app/AnnouncementBanner.jsx",
      "line": 11,
      "claim": "All three copies (each one names the other two apps): the 4-line header comment repeats what announcement.js's header comment and the README already say: unset, malformed or expired means nothing shows; text is plain; a dismissal lasts until the id changes.",
      "scenario": null,
      "action": "Two lines: `// This deployment's announcement (common/announcement.js; README, \"Announcement banner\"),\n// fixed above the app bar. Same component in the Mapper and TBv2.`",
      "lines_delta": -2,
      "confidence": "low"
    }
  ]
}

@paynejd

paynejd commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Codex check of commits f624636..94d8d3b (codex-cli 0.166.0)

Verdict: clean. The simplification commit broke nothing.

findings.json
{
  "reviewer": "codex-cli 0.166.0",
  "perspective": "defects",
  "mode": "final",
  "commit": "94d8d3bd53e5e80e04dc9d404271260d5d1bd000",
  "verdict": "clean",
  "prior": [],
  "findings": []
}

@paynejd

paynejd commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Codex check of commits 94d8d3b..edec8f6 (codex-cli 0.166.0)

An adversarial review (claude subagent) tried to break the escaping and the checks. No input added script to env-config.js. It found two P2s, fixed in edec8f6: a long label pushed the close button off a phone screen (the text now wraps and the button keeps its size), and a timer delayed by a sleeping laptop could keep an ended banner up (until is now checked at least once a minute). It also recorded P3s, which aren't fixed here. The full report is on the private tracker (OpenConceptLab/ocl_online#425).

Verdict: clean. That commit broke nothing.

findings.json
{
  "reviewer": "codex-cli 0.166.0",
  "perspective": "defects",
  "mode": "final",
  "commit": "edec8f65976e2038fec0757e6c1a1ece9898f9e9",
  "verdict": "clean",
  "prior": [],
  "findings": []
}

@paynejd
paynejd merged commit b6bff51 into master Oct 30, 2026
6 checks passed
@paynejd
paynejd deleted the ocl_online-425-announcement-setting branch October 30, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants