Commit graph

12 commits

Author SHA1 Message Date
John McLear
ab60ed33c1
admin: parsed JSONC settings editor (takes over #7666, closes #7603) (#7709)
* admin: parsed JSONC settings editor with form view (#7603, #7666)

Takes over #7666 / closes #7603. Squashed rebase of 32 commits onto
current develop (which has since absorbed admin design rework #7716
and admin i18n fixes #7736 — granular history preserved on
takeover/7666-admin-settings-editor before this squash, see PR
description for the original commit log).

Highlights:

- New parsed JSONC settings editor under
  admin/src/components/settings/ — FormView, ModeToggle,
  ParseErrorBanner, JsoncNode dispatcher, leaf widgets (string,
  number, bool, null, env pill), and pure helpers (comments,
  envPill, jsoncEdit, labels, templateComments).
- ${VAR:default} env placeholders render as editable inline inputs
  that round-trip through the raw textarea (env-pill spec asserts
  this; docker-template spec protects against form-view degradation
  on env-heavy configs).
- Schema-driven help text sourced from settings.json.template,
  inlined at build time via vite (drops the runtime fs.allow
  widening that earlier iterations needed).
- ModeToggle switches between FormView and raw textarea on
  /admin/settings; parse errors surface in a non-blocking banner.
- jsonc-parser dep added; pure helpers wrap modify() for stable
  edits that preserve key order and trailing comments (stops at
  end-of-line so trailing-comment trains don't bleed into the next
  property).
- i18n keys added for form mode, parse error, env pill,
  default_label, and input aria.
- Playwright specs cover form view, env pill, parse error banner,
  raw round-trip, and form-mode regressions called out in #7666
  review (stable React keys from AST offsets, save-toast on server
  ack only, NumberInput draft sync, parse-error flash during
  initial load, .settings CSS conflict resolution, focus retention
  via rAF, IconButton type defaulting to 'button').

Co-authored-by: Ayushi Gupta <ayushigupta36881@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): stabilise React keys to prevent focus loss in settings editor

Switch React keys in JsoncNode and FormView from byte offsets to stable
JSON paths (`getNodePath(...).join('.')`). Byte offsets shift on every
keystroke because the edit changes the surrounding character count,
which forces React to remount inputs and lose focus mid-typing.

- Object children key on the property path.
- Array elements key on their JSON path index.
- Add a Playwright regression test pinning focus stability for array
  element edits.

Co-authored-by: John McLear <john@mclear.co.uk>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): stop trailing /* */ comments from bleeding into next key's label (#7740)

In the parsed settings form view, each key's row was rendering its label
as the previous keys' source lines concatenated together. Root cause:
findLeading() in admin/src/components/settings/comments.ts treated any
line ending in `*/` as a comment continuation, so a JSON line like
  "altF9": true, /* focus on the File Menu and/or editbar */
was absorbed into the next sibling's leading comment block, and then
each subsequent key picked up an even longer accumulation.

- Tighten findLeading's isComment check to only match structural comment
  lines (`//`, `/*`, or a `*`-prefixed continuation/close), so JSON code
  with a trailing block comment no longer matches.
- Surface leading and trailing comments separately from the template
  map. Leaf rows with only a trailing same-line comment now render the
  humanized key as the row label and the comment as the help text below
  the control, matching settings.json.template's convention (and #7740's
  recommendation that "helper text should be below").
- Add unit tests pinning the regression and the JSDoc/`//` leading
  styles, plus a Playwright spec that asserts altC's row carries a
  clean label and the "focus on the Chat window" help text.

Closes #7740.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ayushi Gupta <ayushigupta36881@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 10:41:37 +01:00
John McLear
02e00ad615
fix(admin): restore SearchField + sorting modules used by AuthorPage (#7746)
* fix(admin): restore SearchField + sorting modules used by AuthorPage

PR #7736 ("replace hardcoded German strings with i18n keys") deleted
admin/src/components/SearchField.tsx and admin/src/utils/sorting.ts as
"orphan modules (no longer imported anywhere after #7716)". At that
point, the GDPR admin AuthorPage (PR #7667) had not yet landed on
develop. When #7667 merged afterwards, the new admin/src/pages/AuthorPage.tsx
brought back imports of SearchField and determineSorting — but the
files were already gone, leaving develop with broken admin imports.

This breaks `pnpm --filter admin run build-copy` on every CI job that
builds the admin UI:

  src/pages/AuthorPage.tsx(6,27): error TS2307: Cannot find module
    '../components/SearchField.tsx' or its corresponding type
    declarations.
  src/pages/AuthorPage.tsx(9,32): error TS2307: Cannot find module
    '../utils/sorting.ts' or its corresponding type declarations.
  src/pages/AuthorPage.tsx(207,29): error TS7006: Parameter 'v'
    implicitly has an 'any' type.

Backend tests, Frontend admin tests, Docker (build-test,
build-test-local-plugin, build-test-db-drivers), rate limit, and
Upgrade from latest release all fail at the admin build step on
develop.

Restore both files verbatim from before the deletion (commit ff7c4d531^).
With them back in place AuthorPage.tsx's existing imports resolve, the
implicit-any on the SearchField onChange callback goes away (typed via
SearchFieldProps), and `pnpm --filter admin run build-copy` succeeds.

This is the minimal, surgical fix; whether SearchField / determineSorting
should ultimately be inlined into AuthorPage is a separate cleanup.

Test plan:
  - cd admin && pnpm exec tsc --noEmit        → no errors
  - cd admin && pnpm exec tsc && pnpm exec vite build → built in 545ms

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(admin-i18n-lint): invert orphan-modules assertion now that AuthorPage imports them

PR #7736 added a lint assertion that admin/src/components/SearchField.tsx
and admin/src/utils/sorting.ts must NOT exist, on the assumption they
were dead code after #7716. They aren't — the GDPR AuthorPage (#7667)
imports both. The previous commit restored the files; this commit flips
the assertion so the test:

  - verifies both files exist
  - verifies admin/src/pages/AuthorPage.tsx still imports them

If a future cleanup wants to delete these modules, the test now forces
the author to also delete or refactor the AuthorPage consumption first,
preventing a repeat of the merge-order accident that produced this bug.

Test plan:
  - cd src && pnpm exec vitest run tests/backend-new/specs/admin-i18n-source-lint.test.ts
    → 14 passed (14)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 08:40:15 +01:00
John McLear
6075708818
feat(gdpr): admin UI for author erasure (follow-up to #7550) (#7667)
* docs(gdpr): admin UI for author erasure — design spec

Follow-up to PR5 (#7550): adds an in-product /admin/authors page so
operators can search by name or external mapper, preview the impact
of an Art. 17 erasure (token mappings, mapper bindings, chat
messages, affected pads), and commit it without crafting a curl.
Backend uses three new admin-socket events on settings_admin (not
REST), so the existing public REST endpoint and its
gdprAuthorErasure.enabled flag keep their current single meaning.
The page stays discoverable when the flag is off — banner + disabled
buttons explain how to enable it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(gdpr): admin UI for author erasure — implementation plan

Step-by-step TDD plan for the /admin/authors follow-up to PR5
(#7550). Nine tasks covering: lastSeen field on globalAuthor writes,
anonymizeAuthor({dryRun}), authorManager.searchAuthors helper,
three new admin-socket events (authorLoad / anonymizeAuthorPreview /
anonymizeAuthor) + settings-flag delivery, frontend types/swatch/
i18n, store/route/sidebar wiring, AuthorPage.tsx with two-step
modal and disabled-flag banner, Playwright coverage, and the PR/
Qodo workflow. References the spec at
docs/superpowers/specs/2026-05-03-gdpr-admin-author-erasure-ui-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(authors): stamp lastSeen on globalAuthor writes

Adds a lastSeen timestamp to the globalAuthor record on createAuthor,
setAuthorName, and setAuthorColorId. Read paths are not modified to
keep the write cost zero per page load. Pre-existing records gain the
field on their next identity write — no migration sweep, callers that
read the field tolerate undefined.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(authors): anonymizeAuthor({dryRun}) for preview

Adds an opt-in dryRun option that walks the same token/mapper/chat
loops and returns identical counter shape without touching the
database. The public REST endpoint is unchanged (it never passes the
flag), so production behaviour is identical. Used by the upcoming
admin-UI two-step erase modal to show 'will clear: N mappings, K
chat messages' before the irreversible commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(authors): restore lost rationale on anonymizeAuthor + document dryRun

Code review on the previous commit caught that the dryRun refactor
silently dropped four WHY-comments (lazy-require cycle, drop-mappings-
first ordering, zero-identity-without-sentinel split, sentinel-last
discipline) and left the new opts parameter undocumented. Restored
the comments verbatim and added a one-line JSDoc note for dryRun.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(authors): authorManager.searchAuthors helper

In-memory enumeration of globalAuthor:* with a join on mapper2author:*
for the mapper column. Filter (substring on name OR mapper OR
authorID — the authorID match lets admins verify a specific erased
record where name and mapper bindings are gone), sort
(name | lastSeen), paginate, cap the pre-pagination set at 1000 to
prevent runaway scans. Powers the upcoming /admin/authors page.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(authors): tie-break searchAuthors sort on authorID

Code review pointed out that ties in the primary sort key (common on
lastSeen for authors created the same ms, possible on identical
names too) fell back to findKeys enumeration order — not guaranteed
stable across DB backends. Adding an authorID secondary sort makes
pagination safe across requests. Also fix a misleading 'default'
note in the JSDoc — includeErased is required, not optional.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(authors): admin-socket events for author erasure UI

Adds three handlers on the /settings admin namespace:
- authorLoad: paginated search via authorManager.searchAuthors
- anonymizeAuthorPreview: dry-run counters, always available to
  authenticated admins (read-only)
- anonymizeAuthor: live commit, gated on gdprAuthorErasure.enabled
  (returns {error: 'disabled'} when off)

Extends the load reply with a flags.gdprAuthorErasure boolean so the
client knows whether to render the disabled-flag banner without an
extra round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(authors): clean up admin-socket test mutations

Code review found two test-isolation issues:
- settings.users 'restoration' was a no-op (savedUsers held a
  reference to the same object that the test mutated). The test-admin
  key now gets explicitly removed in after().
- The Promise that awaited connect/connect_error left the loser
  listener attached for the lifetime of the socket, risking an
  unhandled rejection on a later spurious connect_error event.
  Listeners are now paired with off() so only the winner survives.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(admin): types, ColorSwatch, and en.json for authors page

Standalone primitives for the upcoming /admin/authors page:
- AuthorSearch.ts: query/result/preview wire types matching the new
  admin-socket events
- ColorSwatch.tsx: resolves a globalAuthor.colorId (palette index or
  raw hex) to a small inline-styled swatch
- ep_admin_authors/en.json: every user-visible string the page needs,
  loaded by the existing namespace-as-static-asset i18n strategy

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(admin): /admin/authors page

Adds a searchable, sortable, paginated authors page mirroring the
existing PadPage shape. Search matches name OR mapper substring;
'Show erased' toggle off by default; cap-at-1000 hint surfaces when
the backend caps the pre-pagination set. Two-step erase modal: dry-
run preview shows what will be cleared, then a Continue button
commits the irreversible erasure. Disabled-flag banner explains how
to enable when gdprAuthorErasure.enabled is false; per-row Erase
button is disabled with a tooltip in the same condition.

Sidebar gets a Users link between Pads and Communication. App.tsx
listens for the new flags.gdprAuthorErasure on the connect-time
settings push so the page knows the flag state without an extra
round trip. ep_admin_authors namespace is added to i18next's ns
list so all translation keys resolve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(authors): i18n the pagination controls

Spec review caught three hardcoded English strings in the
/admin/authors pagination footer ('Previous Page', 'Next Page',
'X out of Y'). Carried over from PadPage.tsx via the plan template,
which had the same gap. Added three new keys to ep_admin_authors
and routed the spans through Trans/t().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(authors): banner CSS, IconButton attribute drop, erase phase string

Code review on the AuthorPage commit caught three issues:

- Disabled-flag banner used dialog-confirm-content classname which is
  position: fixed + centered + z-index: 101, making it render as a
  modal-style overlay over the table. Drop the className and define
  the banner with inline styles only; add role='alert' for SR users.
- The Erase IconButton spread {data-disabled-reason: …} alongside
  {disabled: true}, but IconButton only forwards a small allowlist of
  props — the data attribute was silently dropped. Replaced with a
  conditional title that flips to the disabled-reason string when the
  button is disabled (which IconButton does forward).
- 'Erasing…' string was rendered during loading-preview, but the
  string literally describes the commit phase. Added a new
  loading-preview key for the preview-loading state, and surface the
  existing 'erasing' string under the buttons during the committing
  phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(admin): Playwright /admin/authors + fix i18n key shape

The earlier en.json shipped namespace-prefixed JSON keys
('ep_admin_authors:title': 'Authors') which is the wrong shape:
i18next splits the lookup on ':' to extract the namespace, then looks
up the bare key in the loaded namespace data. The existing convention
(admin/public/ep_admin_pads/en.json) uses flat keys without the
namespace prefix; matching it makes every
<Trans i18nKey='ep_admin_authors:foo'/> resolve to the intended
translated string. Strings render as English fallback without this
fix; only the page-title test passes (and only by substring accident).

Also adds the Playwright coverage required by Task 8: localized
title, empty-state message on a fresh search tag, disabled banner
toggling with gdprAuthorErasure.enabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): JSONC-tolerant settings parse + sidebar count = 7

CI on PR #7667 surfaced two test failures caused by my changes:

1. setErasureFlag() in admin_authors_page.spec.ts used JSON.parse on
   the raw settings.json textarea content. The CI environment loads
   settings.json.template which has unquoted property names, trailing
   commas, and block + line comments — JSON.parse rejects all three.
   Switched to `new Function('return (' + raw + ')')` which evaluates
   the textarea as a JS object literal, accepting every shape
   Etherpad's own settings loader handles.

2. admintroubleshooting.spec.ts hardcoded
   `menu.locator('li').toHaveCount(6)`. The new /authors sidebar
   entry made it 7. Updated the assertion and the sidebar comment.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(authors): IconButton title, Dialog.Title, preview errors, settings restart

Final whole-branch review found three Important UX/a11y defects, plus
CI flagged one runtime defect:

- IconButton renders the title prop as a visible <span>, not as the
  HTML title attribute. Disabled rows were displaying the 80-character
  'Author erasure is disabled...' string next to every trash icon.
  Reverted to the short 'Erase' label; the page-level banner already
  explains the disabled state.
- Radix Dialog.Content was missing Dialog.Title. Wrapped the existing
  <h3> in <Dialog.Title asChild> so screen readers can announce the
  dialog purpose.
- onPreview proceeded to render the preview UI even when the backend
  reply carried {error}, leaving 'Will clear undefined token
  mappings...' on screen. Now mirrors onErase.
- The disabled-banner-hidden Playwright test failed because
  settings.json save does not hot-reload. setErasureFlag now
  restartEtherpad's after saveSettings and re-logins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(authors): action Qodo review — lastSeen, flag-gating, defensive payloads

Qodo on PR #7667 surfaced three issues:

1. (Bug, Correctness) lastSeen lost or stale.
   - mapAuthorWithDBKey only updated `timestamp` for returning authors
     so the admin /authors 'Last seen' column drifted on every reconnect
     without an identity write. Now stamps both timestamp and lastSeen.
   - anonymizeAuthor's two db.set calls overwrote globalAuthor without
     preserving lastSeen, blanking the column for erased rows. Both
     writes now carry forward `existing.lastSeen ?? existing.timestamp`.
   - searchAuthors falls back to rec.timestamp when rec.lastSeen is
     missing so legacy records aren't blank.

2. (Rule violation, Security) /authors route not flag-gated.
   The new admin-socket read paths (authorLoad, anonymizeAuthorPreview)
   were always-on; only the destructive anonymizeAuthor was gated.
   Project rule (Compliance ID 6) requires new features behind a flag,
   disabled by default. All three handlers now check
   gdprAuthorErasure.enabled and return {error:'disabled'} when off.
   The sidebar 'Authors' link is hidden when the flag is off
   (deep-link to /admin/authors still works and renders the existing
   disabled banner so docs can point to it).

3. (Bug, Reliability) Socket destructure throws on missing payload.
   Handlers signed `async ({authorID}: {authorID: string}) => …`
   threw before try/catch when a client emitted with no payload,
   producing an unhandled rejection. Switched to
   `async (payload: any) => { const authorID = payload?.authorID; … }`.

Test impact: anonymizeAuthorSocket gains two regressions (authorLoad
disabled-shape, payload-less emits don't crash) and updates the
preview-when-flag-off test to assert {error:'disabled'} per the new
gating posture (was 'preview still works'). admintroubleshooting
sidebar-count reverts 7 → 6 since the Authors link is now conditional
on the flag (off by default in the test environment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 08:11:48 +01:00
John McLear
cbe551b432
feat(updater): tier 3 — auto update with grace window (#7607) (#7720)
* feat(updater): scheduled execution state + graceStartTag dedupe field (#7607)

Preparation for Tier 3 of the auto-update subsystem:
- ExecutionStatus gains `scheduled` (targetTag, scheduledFor, startedAt).
- EmailSendLog gains `graceStartTag` for one-shot grace-start email dedupe.
- state validator accepts the new shape, requires per-status fields,
  and backfills graceStartTag=null on a Tier 1/2 state file.

Plus the implementation plan at
docs/superpowers/plans/2026-05-11-auto-update-pr3-tier3-auto.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): decideSchedule pure decision function (#7607)

Adds src/node/updater/Scheduler.ts with the Tier 3 pure decision logic:
- schedules when canAuto + idle/verified/terminal-cleared
- reschedules when a newer tag appears mid-grace
- emits a grace-start email (once per tag) when adminEmail is set
- cancels a stale schedule when policy flips canAuto off
- no-ops during in-flight / terminal states
- clamps preApplyGraceMinutes to [0, 7 days]

Also extends Notifier's EmailKind union with 'grace-start' so the
decision result types correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): scheduler timer runner with arm/cancel (#7607)

Adds createSchedulerRunner to Scheduler.ts:
- arm(): clears any prior timer, sets a fresh one for scheduledFor
- cancel(): clears the pending timer, idempotent
- past scheduledFor → fires with delay=0 (rehydrate after restart-in-grace)
- single-fire-per-arm semantics; armedFor cleared on fire

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor(updater): extract apply pipeline shared by HTTP + scheduler (#7607)

Lifts the preflight → drain → execute orchestration out of the
/admin/update/apply HTTP handler into src/node/updater/applyPipeline.ts.
The HTTP handler keeps its 4xx status mapping; the pipeline owns the
state transitions, lock release, drain coordination, and rollback hand-
off. The new ApplyPipelineDeps interface accepts an onAccepted callback
so the HTTP path can still 202 mid-flow while the Tier 3 scheduler path
(next commit) can no-op.

Adds `scheduled` to the apply allowed-entry list so an admin can "Apply
now" during the Tier 3 grace window.

13 vitest cases cover happy / preflight-failed / cancelled / busy /
lock-held / scheduled-entry / rollback / lock-release. Existing 12
mocha integration tests still pass without change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): wire Tier 3 scheduler into boot + performCheck (#7607)

- expressCreateServer instantiates the scheduler runner and rehydrates
  the timer when a prior boot left state.execution = scheduled
- performCheck evaluates decideSchedule after the notifier pass:
  schedule transitions state + sends grace-start email + arms timer;
  cancel-schedule resets to idle + cancels timer
- shutdown cancels the timer
- exposes cancelScheduler() so the cancel endpoint (next commit) can
  drop the pending schedule
- buildSchedulerApplyDeps() supplies the full production-wired pipeline
  deps (preflight, executor, rollback) for the scheduler-triggered apply

Adds tests/backend/specs/updater-scheduler-integration.ts covering
boot-rehydrate fire-on-past and the decision-to-state round-trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): cancel handler supports Tier 3 scheduled state (#7607)

POST /admin/update/cancel now accepts execution.status === 'scheduled'
in addition to preflight/draining. The handler calls cancelScheduler()
to drop the pending in-process timer, then transitions state to idle
with lastResult.outcome = 'cancelled' (mirroring the existing pattern).

Adds a Tier 3 integration test that seeds a scheduled state, calls
/admin/update/cancel, and asserts the state machine landed correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(admin): countdown + cancel UI for Tier 3 scheduled updates (#7607)

- store.ts: extend Execution union with the scheduled variant
- UpdatePage.tsx: render countdown panel during scheduled; Apply button
  is relabelled "Apply now" so the admin can skip the remaining grace;
  Cancel button accepts scheduled state
- UpdateBanner.tsx: dedicated scheduled banner with live remaining time
- en.json: new i18n keys (execution.scheduled, banner.scheduled,
  page.scheduled.{title,countdown,apply_now})

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(updater): playwright spec for Tier 3 scheduled UI (#7607)

Three cases against a mocked /admin/update/status:
- countdown panel + Apply now + Cancel render when execution is scheduled
- Cancel button posts /admin/update/cancel and triggers re-fetch
- /admin (banner) shows "Auto-update to <tag> scheduled" copy

Mirrors the existing update-page-actions.spec.ts mock pattern (page.route).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(updater): document Tier 3 auto with grace window (#7607)

- doc/admin/updates.md: flip Tier 3 from "designed, not yet implemented"
  to current; expand preApplyGraceMinutes table row; add a Tier 3
  section explaining schedule / cancel / Apply now / restart-in-grace
  and the grace-start email
- settings.json.template: clarify the preApplyGraceMinutes comment
- CHANGELOG.md: Unreleased entry for Tier 3
- runbook §11: full Tier 3 smoke (happy, cancel, apply-now, restart-in-
  grace, email) plus the additional sign-off checkboxes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): UpdatePage handles missing execution field; scope spec locator (#7607)

Two CI fixes for PR #7720:

1. UpdatePage.tsx — optional-chain us.execution.status. Integration test
   stubs (update-banner.spec.ts) ship payloads without the Tier 2/3
   execution / lastResult / lockHeld fields; without optional chaining
   on the new scheduled-derivation line the whole page crashed before
   the h1 rendered, breaking the unrelated "renders current version"
   test.

2. update-scheduled.spec.ts — scope the v2.7.2 assertion to the
   .update-scheduled section. The regex was matching three elements
   (banner, countdown panel, changelog link) and tripped Playwright's
   strict-mode locator check.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(updater): address Qodo review (Tier 3 race conditions + tier-off bypass) (#7607)

Four fixes for bugs flagged by Qodo's review of PR #7720:

1. **Tier=off bypasses scheduler** (correctness). expressCreateServer
   used to instantiate the scheduler and rehydrate any persisted
   `scheduled` state regardless of `updates.tier`. A user who set
   `tier: "off"` after a schedule had been persisted would still see
   the timer fire after restart. The boot path now skips scheduler
   creation when tier is off and explicitly clears a stale scheduled
   state to idle (logged so the admin sees what happened).

2. **Timer fire skips state recheck** (reliability). The scheduler's
   timer callback called applyUpdate() directly. Race: admin clicks
   Cancel at the same instant the timer fires, or the tier flips
   during the grace window. Now schedulerTriggerApply re-loads state
   and re-evaluates policy via a new pure decideTriggerApply() helper
   in Scheduler.ts. If state is no longer scheduled (or scheduled for a
   different tag), aborts. If policy now denies auto, persists state
   back to idle and aborts.

3. **Apply-now leaves scheduler timer armed** (correctness). The apply
   endpoint accepts `scheduled` as an entry status but didn't cancel
   the in-process scheduler timer. After the admin clicks Apply now,
   the still-armed timer could later fire and attempt another apply
   (especially if the manual one finishes in preflight-failed, which
   is also an allowed-entry status). Apply handler now calls
   cancelScheduler() when entering from `scheduled`.

4. **scheduledFor not validated as timestamp** (reliability). State
   validator only required scheduledFor / startedAt etc. to be
   non-empty strings; a hand-edited "scheduledFor": "garbage" would
   pass validation and yield NaN delay → immediate fire. The
   validator now requires known timestamp fields to be parseable
   via Date.parse().

Tests: 6 new decideTriggerApply cases + 3 new state.ts validation
cases. 189 vitest pass / 29 mocha integration pass / ts-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 20:50:06 +01:00
John McLear
ff7c4d5310
fix(admin): replace hardcoded German strings with i18n keys (#7735) (#7736)
* fix(admin): replace ~50 hardcoded German strings with i18n keys (#7735)

PR #7716 ("chore: fixed admin design rework") rebuilt admin/src/pages
with literal German copy inline — "Update verfügbar", "Aktualisieren",
"Keine Pads gefunden", "Hook-Bindings", "de-DE" date formatters, etc.
Non-DE users see a French/English/German salad: <Trans i18nKey="…"/>
calls resolve correctly via translatewiki, but every literal stays
German regardless of browser locale.

This change:

  - Adds 90+ keys to src/locales/en.json under admin.*, admin_login.*,
    admin_pads.*, admin_plugins.*, admin_plugins_info.*, admin_settings.*,
    admin_shout.*, and the previously-orphaned update.page.{disabled,
    unauthorized,error}.
  - Replaces every hardcoded literal in admin/src/{App,LoginScreen,
    HomePage,HelpPage,PadPage,SettingsPage,ShoutPage,UpdatePage}.tsx with
    t() or <Trans>.
  - Threads i18n.language into PadPage so relativeTime() and
    toLocale*() honour the user's locale instead of forcing de-DE.

Test coverage:

  - src/tests/backend-new/specs/admin-i18n-source-lint.test.ts (vitest):
    scans admin/src/pages/*.tsx + App.tsx for a denylist of German
    literals introduced by #7716, asserts PadPage no longer hardcodes
    'de-DE', and pins the set of new en.json keys.
  - src/tests/frontend-new/admin-spec/admini18n.spec.ts (Playwright):
    extended to assert rendered English text on every page (Home, Pads,
    Help, Login) and verify no German leakage on the English path.

Non-EN locales pick up translations from translatewiki on its normal
cadence; until then i18next falls back to en.json.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): reuse existing ep_admin_pads:* keys instead of duplicating

Pre-rework admin already had:
  ep_admin_pads:ep_adminpads2_action       ("Action")
  ep_admin_pads:ep_adminpads2_last-edited  ("Last edited")
  ep_admin_pads:ep_adminpads2_no-results   ("No results")

Initial pass added admin_pads.{col.action, col.last_edited,
sort.last_edited, empty_state} duplicating those — drop the duplicates
from en.json and point PadPage.tsx at the existing translatewiki-fed
keys. Stats/column heads that genuinely didn't exist before
(admin_pads.col.{pad,users,revisions}, the filter chips, relative-time,
pagination, etc.) stay as new keys.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(admin): address Qodo finding + restore #7716 regressions

Qodo flagged a reliability bug in PadPage on PR #7736: i18n.language
flows from user-controlled ?lng= straight into Intl.* formatters, which
throw RangeError on malformed tags (e.g. 'en_US', '💥'). Crashing the
pads page on a crafted URL.

Wrap the locale in a sanitizeLocale() helper that normalises '_' → '-'
and validates via Intl.DateTimeFormat.supportedLocalesOf(), falling back
to 'en' so dates render in a sane locale rather than the user's browser
default fighting page copy.

Same audit surfaced four additional regressions from #7716 still on
develop, fixed here on-theme:

  - HomePage dropped <a href="https://npmjs.com/..."> wrappers on both
    installed and available plugin rows. Restored with .pm-plugin-link.
  - "Downloads" column / "Most popular" default sort / "Popular" tag
    were dead UI — src/static/js/pluginfw/installer.ts::search() never
    populates `downloads`. Removed the column, default sort, and tag;
    dropped `downloads` from PluginDef + SearchParams.sortBy.
  - PadPage sort dropdown hardcoded `ascending: e.target.value ===
    'padName'`, leaving no way to invert direction. Replaced with a
    paired ↑/↓ button (.pm-sort-dir) for both HomePage and PadPage.
  - "1 Core" stat hint hardcoded count=1. Derived from
    installedPlugins.filter(p => p.name === 'ep_etherpad-lite').length.
  - Deleted orphan modules SearchField.tsx and sorting.ts (no longer
    imported anywhere after #7716).

Tests:

  - admin-i18n-source-lint.test.ts: +3 assertions (sanitizeLocale
    pattern, dead-downloads check, orphan-module deletion, sort-dir
    toggle) → 14 passing.
  - admini18n.spec.ts: +2 assertions (npmjs link on ep_etherpad-lite
    row, sort-direction toggle visible).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(agents): add mandatory i18n + a11y guardrails

PR #7716 ("admin design rework") shipped ~50 hardcoded German literals,
dropped npmjs.com link affordances, removed the sort-direction control
on PadPage, and forced `de-DE` into Intl formatters — none of which the
AGENTS.MD guide explicitly forbade. Document the rules so the next UI
refresh cannot regress these in the same way:

- i18n section spells out which slots must be localised (JSX text,
  placeholders, titles, aria-labels, alts, toasts, options, alerts),
  which API to use per surface (<Trans>/t() in React, data-l10n-id in
  the legacy pad UI, never window._ rebound), where keys live
  (src/locales/en.json — never hand-edit non-EN locales), to reuse
  existing keys before duplicating, pluralisation via _one/_other,
  defaultValue is safety not a substitute, and points at the
  source-lint test that enforces the denylist.

- a11y section spells out the lessons surfaced by the audit: icon-only
  buttons need aria-label AND title (both localised), sort controls
  must be focusable + reversible, semantic HTML over div soup, external
  navigation is <a>, "don't drop affordances when restyling" is a
  hard rule, Playwright specs must assert rendered strings + at least
  one structural affordance for UI changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:09:44 +01:00
John McLear
efb8328084
feat(updater): tier 2 — manual-click update from /admin/update (#7607) (#7704)
* docs(updater): PR 2 (Tier 2 manual-click) implementation plan

20-task TDD plan for shipping the manual-click update flow on top of the
Tier 1 (notify) work merged in #7601. Covers UpdateExecutor, RollbackHandler,
SessionDrainer, lock + trustedKeys, four admin endpoints (apply / cancel /
acknowledge / log), admin UI updates, integration tests against a tmp git
repo, and a manual smoke runbook for the spec's "before each tier ships"
gate. Plan deliberately scopes signature verification to an opt-in stub
(updates.requireSignature: false default) to avoid blocking on a separate
release-signing project.

Plan: docs/superpowers/plans/2026-05-08-auto-update-pr2-manual-click.md
Spec: docs/superpowers/specs/2026-04-25-auto-update-design.md
Issue: ether/etherpad#7607

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): extend state + settings for Tier 2 manual-click

Adds ExecutionStatus discriminated union, bootCount, and lastResult to
UpdateState, plus the preApplyGraceMinutes/drainSeconds/diskSpaceMinMB/
requireSignature/trustedKeysPath knobs that Tier 2's executor needs.
loadState backfills the new fields on Tier 1 state files so existing
installs keep working.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): PID-based update.lock with stale-pid reaping

Single-flight guard for Tier 2's UpdateExecutor. Atomic O_CREAT|O_EXCL
acquire; on EEXIST, sends signal 0 to the recorded PID and reaps if dead.
Unparseable / partially-written lock files are treated as stale rather
than fatal so a half-written lock from a SIGKILL'd parent doesn't lock
the install out forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): verifyReleaseTag — gpg-via-git stub for Tier 2 preflight

Default updates.requireSignature=false: log a warning and return ok with
reason=signature-not-required. Set true to make preflight refuse a tag
whose signature does not verify under the system keyring (or
trustedKeysPath via GNUPGHOME). Etherpad's release process does not yet
sign tags consistently; turning the check on by default would break
Tier 2 for every admin and forcing a release-signing change is out of
scope for this PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): preflight check pipeline for Tier 2

Pure orchestrator over injected probes for install-method, working tree,
disk space, pnpm presence, lock state, remote tag existence and
signature verification. Cheap-and-definitive checks run first; first
failure short-circuits with a typed reason that the route layer will
surface in the preflight-failed admin banner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): rolling update.log helpers (appendLine + tailLines)

Direct file-append + size-based rotation rather than a log4js appender —
avoids re-configuring log4js on top of the user's existing logconfig.
appendLine creates parents, rotates at 10MB (configurable), keeps 5
backups by default. tailLines reads the last N lines for /admin/update/log.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): SessionDrainer + handshake guard

Drainer schedules T-60 / -30 / -10 broadcasts and resolves at T=0;
isAcceptingConnections() flips off for the duration. PadMessageHandler
consults the flag at the start of CLIENT_READY and disconnects new
joiners with reason "updateInProgress" — existing sockets are
unaffected. Drains shorter than 30s collapse the early timers to fire
ASAP rather than queue past the drain end.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): UpdateExecutor — snapshot, fetch/checkout/install/build, exit 75

Pure-DI orchestrator: spawnFn, copyFile, readSha, saveState, exit are all
injected so unit tests run the full pipeline without spawning real
children or mutating the real install. Streams stdout/stderr to
update.log via the now-best-effort appendLine helper (swallows fs errors
so the executor itself never breaks on read-only / unwritable log dirs).
Failure paths transition to rolling-back and return — the route layer
hands off to RollbackHandler which owns the rollback exit, so we don't
double-exit and lose tail lines.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): RollbackHandler — health-check timer + crash-loop guard

checkPendingVerification arms a 60s timer at boot when state is
pending-verification and increments bootCount; bootCount>2 forces an
immediate rollback (crash-loop guard). markVerified persists the
verified state and stops the timer. performRollback restores the
backup lockfile, runs git checkout <fromSha> and pnpm install, lands on
rolled-back or rollback-failed (terminal) on sub-step failure, exits 75
either way so the supervisor restart brings the new state up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): wire RollbackHandler into boot + UpdatePolicy honours rollback-failed

- expressCreateServer now invokes checkPendingVerification before polling starts
  so a previous boot's pending-verification either re-arms the health-check
  timer or, when bootCount has climbed past the crash-loop threshold, forces
  an immediate rollback.
- server.ts calls markBootHealthy after state hits RUNNING so /health-being-up
  is the implicit happy-path signal that cancels the rollback timer.
- /admin/update/status surfaces execution + lastResult + lockHeld so the admin
  UI can render the right Apply / Cancel / Acknowledge state.
- UpdatePolicy gains an `executionStatus` input. While it equals 'rollback-failed',
  canAuto / canAutonomous are denied (reason: rollback-failed-terminal); manual
  stays on because clicking Apply IS the intervention the terminal state needs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): apply / cancel / acknowledge / log endpoints

Strict admin-only POSTs that drive Tier 2's manual-click flow:
- POST /admin/update/apply: acquire lock, persist preflight, run preflight,
  drain $drainSeconds, executeUpdate (which exits 75 on success), or run
  performRollback on a failure path (also exits 75).
- POST /admin/update/cancel: cancel a pre-execute drain/preflight, write
  cancelled lastResult, release lock.
- POST /admin/update/acknowledge: clear terminal states (preflight-failed,
  rolled-back, rollback-failed) back to idle. lastResult is preserved so
  the admin still sees what happened.
- GET /admin/update/log: tail var/log/update.log (200 lines) for the in-
  progress UI. Strict admin auth.

Also:
- socketio hook exports getIo() so the apply endpoint can broadcast the
  drain shoutMessage outside the regular hook surface.
- ep.json registers updateActions after admin/updateStatus.
- 11 mocha integration tests cover auth, policy denial, execution-busy,
  acknowledge-clears-terminal, log content-type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): admin UI Apply/Cancel/Acknowledge + live log stream

UpdatePage renders the right action set based on execution.status:
Apply when idle/verified and policy allows, Cancel during
preflight/draining, Acknowledge on terminal preflight-failed /
rolled-back / rollback-failed. While the executor is in flight
(preflight/draining/executing/rolling-back) the page polls
/admin/update/log + /admin/update/status once a second and shows the
rolling tail; polling stops automatically when the run terminates.

lastResult and policy denial reasons surface localised copy. Buttons
disable themselves while a network round-trip is in flight to dodge
double-clicks. New i18n keys live under update.page.{apply,cancel,
acknowledge,log,execution,policy.*,last_result.*}, update.execution.*,
update.banner.terminal.rollback-failed, and update.drain.{t60,t30,t10}.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): pad shoutMessage renders update.drain.* via html10n

broadcastShout now sends {messageKey, values, sticky} so the existing
pad-side shout pipeline can route through html10n.get(). The renderer
gains a values pass-through so update.drain.t60 etc. interpolate
{{seconds}}, and gives updater shouts a different gritter title (the
banner.title localised string) so users know it's a system event
rather than a generic admin message.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): rollback uses git checkout -f + integration suite over tmp git repo

RollbackHandler now does git checkout -f <fromSha> BEFORE overlaying the
backup lockfile. Without -f, git refuses checkout when there are
unstaged modifications to files it would overwrite — exactly the case
after a partial executor run that mutated the working tree. With -f the
partial mutation is discarded and the working tree returns to fromSha
cleanly. The backup-lockfile copy is still done (belt-and-braces) but
tolerates ENOENT since checkout already restored the right lockfile.

The new integration suite at src/tests/backend/specs/updater-integration.ts
exercises the full pipeline against a disposable git repo: happy path,
install-fail rollback, build-fail rollback, crash-loop guard, and a
target-sha-doesn't-exist rollback-failed terminal case. 5 mocha tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(updater): Playwright admin Apply / Cancel / Acknowledge flow

Stubs /admin/update/status (and /admin/update/apply for the apply path)
at the route level so we can assert UI transitions without actually
running an update. Four scenarios:
- Apply button POSTs and re-fetches status (>=2 status fetches total).
- install-method-not-writable hides the button and shows localised
  denial copy.
- rollback-failed terminal state shows the Acknowledge button and the
  "Manual intervention required" lastResult copy.
- lockHeld=true hides Apply even when policy.canManual is on.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(updater): admin banner shows rollback-failed terminal alert

When execution.status === 'rollback-failed' the banner switches to a
role=alert with the strong update.banner.terminal.rollback-failed copy
and overrides the regular "update available" framing — an admin who
left the system in this state needs to fix it before any other admin
work matters. Other terminal states (preflight-failed, rolled-back) are
informational and surface on the page itself, not the banner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(updater): Tier 2 admin docs + manual smoke runbook + CHANGELOG

doc/admin/updates.md gains a full Tier 2 section: prerequisites
(git install + process supervisor with sample systemd unit), Apply
flow with timings, every failure mode and the resulting state, the
four endpoints, and the signature-verification opt-in. Settings
table picks up the new updates.* knobs.

docs/superpowers/specs/2026-04-25-auto-update-runbook.md is the
manual smoke runbook the design spec calls for: disposable VM,
systemd unit, every observable transition (happy path, install/
build-fail rollback, crash-loop guard, rollback-failed terminal,
cancel during drain) plus a sign-off checklist for the release cut.

CHANGELOG Unreleased section explains the supervisor requirement
and points readers at the runbook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(updater): note docker-friendly update flows as follow-up work

Tier 2 refuses Apply on installMethod=docker because in-container
mutation doesn't survive a container restart. Adds a future-work note
covering the two reasonable paths for an in-product docker Apply
button (instructions-only vs deploy-webhook) and explicitly rules out
mounting /var/run/docker.sock as a footgun. Watchtower gets a pointer
for admins who want fully autonomous docker updates today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(updater): address Qodo review (1-6) + Playwright strict-mode CI fix

1. Tier 2 endpoints now gate on tier in {manual, auto, autonomous} —
   notify and off return 404 to match the prior PR-1 behaviour. Gate is
   evaluated per-request via app.use middleware so a settings.json reload
   takes effect without a full restart, and so integration tests can flip
   the tier dynamically. Adds a regression test that exercises 404 at
   tier=notify across all four endpoints.

2. cancel/apply race fixed: /admin/update/cancel no longer releases the
   lock — apply's finally block owns it for the request's lifetime. Apply
   now reloads state after preflight and aborts with 409 cancelled-during-
   preflight if execution.status is no longer 'preflight' for the same
   targetTag. Prevents a second apply from sneaking in while the first is
   still running its slow checks, and prevents the post-cancel apply from
   continuing into drain/execute.

3. SessionDrainer now restores acceptingConnections=true at drain
   completion (not just on cancel). The lock + persisted execution.status
   prevent a fresh apply from racing in — the in-memory flag was redundant
   safety that turned into a wedge if the executor threw post-drain. Adds
   a unit test asserting the flag is restored after natural drain end.

4. PadMessageHandler drain guard switched from socket.json.send (a
   socket.io v2/v3 API that may not exist on v4) to socket.emit('message',
   ...) for consistency with the other disconnect paths in the file.

5. Spawn 'error' handlers added to runStep helpers in UpdateExecutor and
   RollbackHandler, plus the gpg verify-tag spawn in trustedKeys. Without
   them, a missing/unexecutable binary leaves the promise hanging forever
   and the update flow stuck in-flight. SpawnFn type extended to allow
   on('error', ...) listeners cleanly. Spawn errors now resolve with code
   1 + the error message in stderr, so the existing failure-detection
   branches fire normally.

6. executeUpdate body wrapped in try/catch. An exception from readSha,
   saveState, copyFile, or any step now lands in a rolling-back persist +
   returns failed-checkout, so the route's post-executor rollback path
   picks it up. State can no longer wedge at 'executing'. The catch's
   inner saveState is itself try/wrapped so a write-after-write failure
   doesn't crash the route either.

CI: Playwright update-page-actions strict-mode violation fixed. Both the
banner and the lastResult <p> contain "Manual intervention required";
selector now scopes to p.last-result-rollback-failed for the lastResult
assertion specifically.

129 vitest unit tests + 23 mocha integration tests passing; ts-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(updater): address Qodo #7 (status leak) + #8 (short-drain values)

#7. /admin/update/status now redacts diagnostic strings for unauth callers
even when requireAdminForStatus is left at its default (false). Status
enum + outcome enum are kept (the admin banner / pad-side badge need them
to render the right UI) but execution.reason / execution.fromSha /
execution.targetTag and the same fields on lastResult are stripped.
Authed admin sessions still get the full payload — they're looking at
their own server's diagnostics. Two new mocha tests cover both paths:
"redacts execution.reason / lastResult.reason for unauth callers" and
"returns full diagnostic payload to authed admin sessions".

#8. SessionDrainer no longer schedules T-30 / T-10 broadcasts when the
configured drainSeconds can't honour them. Previously, with drainSeconds
< 30 the T-30 timer fired at zero remaining but the broadcast still
claimed "30 seconds" — misleading. Now T-30 only schedules when
drainSeconds > 30 and T-10 only when > 10. Admins picking a short drain
get fewer announcements but each carries an accurate countdown. The
opening announcement now reports the configured drain length rather
than a hardcoded 60. Two updated unit tests: drainSeconds=15 (skips
T-30, still fires T-10) and drainSeconds=5 (skips both).

131 vitest unit + 26 mocha integration tests passing; ts-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(updater): address Qodo follow-up — tag injection, rollback rejections, state validation

Qodo posted three new concerns after the first fix push.

1. Git tag option injection (security). The release tag from GitHub's
   tag_name flowed into `git checkout` / `git verify-tag` as a positional
   arg. A tag starting with '-' would be parsed as an option and could
   bypass signature verification or change checkout semantics. Mitigated
   in three layers:

   - New refSafety helper (isValidTag / assertValidTag / refsTagsForm)
     enforces a strict subset of git's check-ref-format spec: rejects
     leading '-' or '.', whitespace, control chars, and ~ ^ : ? * [ \\
     and the '..' sequence.
   - VersionChecker validates tag_name before persisting to state, so a
     malformed value from a misconfigured githubRepo never lands on disk.
   - UpdateExecutor calls assertValidTag and uses the refs/tags/<tag>
     form for git checkout. trustedKeys also validates and adds '--' to
     git verify-tag for an end-of-options marker. updateActions does an
     up-front isValidTag check on state.latest.tag so a corrupt state
     file gets a clean 409 instead of a 500.

2. Unhandled rollback rejections. checkPendingVerification was firing
   `void deps.saveState(...)` and `void performRollback(...)` without
   .catch(), so an fs error during boot's rollback path would bubble out
   as an unhandled rejection. Both callsites now go through fireSaveState
   / fireRollback helpers that catch and log; rollback rejections fall
   through to a best-effort terminal-state write + exit 75 so the
   supervisor can re-try the next boot with bootCount++.

3. Execution state under-validated. isValidExecution previously checked
   only that `status` was a known enum value, so a hand-edited state file
   with `{execution: {status: 'pending-verification'}}` (missing fromSha
   / targetTag / deadlineAt) would pass validation and reach
   RollbackHandler with undefined refs. The validator now consults a
   per-status required-fields map mirroring the ExecutionStatus union in
   types.ts and rejects empty strings as well as missing fields. Same
   tightening applied to lastResult.outcome (must be in the allowed enum,
   not just any string). Six new unit tests cover hand-edited corruption.

145 vitest + 26 mocha tests green; ts-check clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:00:07 +01:00
John McLear
e39dbde887
feat(updater): tier 1 — notify admin and pad users of available updates (#7601)
* docs(updater): add four-tier auto-update design spec

Four-tier opt-in self-update subsystem (off / notify / manual / auto / autonomous).
GitHub Releases as source of truth; install-method auto-detection with admin
override; in-process execution with supervisor restart; 60s drain + announce;
auto-rollback on health-check failure with crash-loop guard. Pad-side severe/
vulnerable badge that does not leak the running version. Top-level adminEmail
with escalating cadence (weekly while vulnerable, monthly while severe).

Refs: docs/superpowers/specs/2026-04-25-auto-update-design.md

* docs(updater): add PR 1 (Tier 1 notify) implementation plan

Bite-sized TDD task breakdown for shipping Tier 1 notify only:
- VersionChecker, InstallMethodDetector, UpdatePolicy, Notifier, state modules
- /admin/update/status (admin-auth) and /api/version-status (public, no version leak)
- Admin UI banner + read-only update page + nav link
- Pad-side severe/vulnerable footer badge
- Settings: updates.* block + top-level adminEmail
- Tests: vitest unit + mocha integration + Playwright admin/pad
- CHANGELOG + doc/admin/updates.md

PRs 2-4 (manual/auto/autonomous) get their own plans after PR 1 lands.

* feat(updater): add shared types for auto-update subsystem

* feat(updater): clarify OutdatedLevel and EMPTY_STATE doc, drop path header

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add semver helpers and vulnerable-below parser

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): tighten semver regex to reject four-part versions

* feat(updater): add state persistence with schema validation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): reject null email and array latest in state validation

typeof null === 'object' meant {email:null} passed the old isValid check,
which would crash downstream Notifier code reading email.severeAt. Likewise,
an array would pass the typeof latest === 'object' branch. Introduce
isPlainObject helper (null-safe, Array.isArray guard) and use it for both
fields. Adds two regression tests covering the exact broken inputs.

* feat(updater): add install-method detector with override

* feat(updater): add policy evaluator

* feat(updater): add GitHub Releases checker with ETag support

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): validate release fields and preserve ETag on prerelease

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add email cadence decider

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): tagChanged email fires regardless of cadence; drop unused field

* feat(settings): add updates.* and adminEmail settings

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): wire boot hook and periodic checker

Register expressCreateServer/shutdown hooks in ep.json and implement
the boot-wiring module that detects install method, starts the polling
interval and runs the notifier dedupe pass each tick.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(updater): add /admin/update/status and /api/version-status endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* i18n(updater): add english strings for update banner, page, and pad badge

* feat(updater): add pad footer badge for severe/vulnerable status

* feat(admin-ui): add update banner, page, and nav link

Add UpdateStatusPayload to the zustand store, a persistent UpdateBanner
rendered in the App layout, a /update page showing version details and
changelog, and a Bell nav link — all wired to the /admin/update/status
endpoint added in Task 10.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(updater): add Playwright specs for admin banner/page and pad badge

* docs(updater): document tier 1 settings, badge, email cadence

* refactor(updater): dedupe helpers, fix misleading log, add banner styling

- Export stateFilePath from index.ts and import it in updateStatus.ts (removes local duplicate)
- Import getEpVersion from Settings.ts in both index.ts and updateStatus.ts (removes two local definitions)
- Fix misleading 'backing off' log message — no backoff is implemented, just retries at next interval
- Remove EMPTY_STATE_FOR_TESTS re-export from state.ts; state.test.ts now imports EMPTY_STATE directly from types.ts
- Add .update-banner and .update-page CSS rules to admin/src/index.css

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(updater): address review feedback — async wrap, tier=off skip, poll race, opt-in admin gate

- Wrap /api/version-status and /admin/update/status with a small async helper
  so a rejected promise becomes next(err) instead of an unhandled rejection.
- Short-circuit route registration when updates.tier === 'off' so the heavier
  opt-out also removes the HTTP surface (matches pre-PR behavior for that case).
- Add an in-flight guard around performCheck() so overlapping interval ticks
  can't race on update-state.json writes or duplicate email decisions; track
  the initial setTimeout handle and clear it in shutdown().
- Add updates.requireAdminForStatus (default false) so admins can lock
  /admin/update/status to authenticated admin sessions without disabling the
  updater. Default false preserves current behavior (the running version is
  already exposed publicly via /health). Backend specs cover unauth → 401,
  non-admin → 403, admin → 200.
- Bump admin troubleshooting menu count test 5 → 6 to account for the new
  Update nav link.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(updater): address Qodo round-2 review feedback

Round 2 of Qodo review on #7601. Addressing the action-required items:

#1 Badge bypassed pad baseURL — derive basePath the same way
   padBootstrap.js does (`new URL('..', window.location.href).pathname`)
   and prefix the fetch with it. Subpath deployments now reach
   /<prefix>/api/version-status instead of 404ing.

#2 Updater poller could get stuck — `getCurrentState()` is now inside
   the try/finally so a one-time loadState() rejection can't leave
   `checkInFlight=true` and permanently silence polling.

#3 Updates off hung admin page — UpdatePage now self-fetches and
   renders explicit `disabled` (404), `unauthorized` (401/403), and
   `error` states instead of staying on "Loading...". Banner-driven
   prefetch is still honoured if it landed first.

#11 NaN polling interval — coerce `checkIntervalHours` to a number,
   clamp to [1h, 168h], log a warning and fall back to 6h on
   non-finite input. Math.max(1, NaN) === NaN previously meant a
   malformed settings.json could turn the poller into a tight loop.

#13 State validation accepted broken subfields — `isValid()` now
   inspects `latest.{version,tag,body,publishedAt,htmlUrl,prerelease}`,
   `vulnerableBelow[].{announcedBy,threshold}`, and
   `email.{severeAt,vulnerableAt,vulnerableNewReleaseTag}`. A
   hand-edited file with a number where a string is expected is now
   treated as corrupt and reset to EMPTY_STATE rather than crashing
   later in semver parsing or email rendering.

#14 Badge cache stampede — wrap `computeOutdated()` in a single-flight
   promise so concurrent requests at cache expiry await one shared
   computation instead of fanning out into N redundant disk reads.

Plus six new state.test.ts cases covering each new validation guard.

Pushing back on the remaining items:

#4 `updates.tier` defaults to `notify` — intentional. The whole point
   of tier 1 is to surface the "you are behind" signal to admins by
   default. Opt-in defeats the purpose; the existing failure mode
   (admin never hears about a security-relevant release) is exactly
   what this PR is fixing.

#5/#8 Admin status endpoint admin-auth — `currentVersion` is already
   public via `/health`, so wrapping the route in admin-auth doesn't
   reduce the disclosure surface meaningfully. Operators who want it
   gated set `updates.requireAdminForStatus=true` (already wired and
   covered by the comment on the route handler).

#10 Plain `https://` URLs in planning doc — planning markdown is
   viewed in editors and on GitHub where protocol-relative URLs would
   either render literally or break entirely. Keeping `https://`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-01 20:02:12 +08:00
John McLear
09d782f695
Enforce 2-space indentation across codebase (#7426)
* Enforce 2-space indentation across codebase

Convert all 4-space indented source files to 2-space to match
.editorconfig and project contributor guidelines.

74 files converted: admin UI components, type definitions, security
modules, test files, helpers, and utilities.

No functional changes — 2882 insertions, 2882 deletions (pure
whitespace).

Fixes #7353

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* Limit admin tests to chromium and firefox

Webkit is already tested in the dedicated frontend-tests workflow.
Running it again in admin tests causes flaky failures due to slow
socket connections and external API timeouts on webkit CI runners.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 19:48:45 +01:00
SamTV12345
a5413d7272
chore: added possibility to create pads via admin menu (#7100) 2025-08-26 21:51:13 +02:00
SamTV12345
840fc982c4 Fixed comp problems 2024-12-22 16:54:56 +01:00
SamTV12345
e12be96102
feat(admin): Added shoutout to admin panel (#6346)
* Added shoutout

* Added shoutout function

* Fixed test.

* Included feedback from review.

* Removed unnecessary file
2024-04-21 17:58:51 +02:00
SamTV12345
73dff0bfe7
Begin redesigning admin panel. (#6219)
* Begin redesigning admin panel.

* Added monaco editor.

* Fixed tests
2024-03-13 15:47:02 +01:00