Plugin: allow ATT_HC_API_URL / ATT_HC_API_KEY via settings screen (not just wp-config)

Some managed hosts make editing wp-config.php impractical or explicitly forbid it (WPE, some resellers, some clients' own operations teams). Add a settings-screen alternative to the existing constants.

Design (mirror the pattern already used for the Gitea recovery config on the same settings page):

- Two new WP options: att_hc_api_url and att_hc_api_key (autoload=false on the key).
- ATT_HC_Api::url() and ATT_HC_Api::key() accessors: return the constant if defined + non-empty, else the option. All existing consumers switch to these accessors instead of the constants directly.
- config_error() message updates to say 'add to wp-config.php OR set via Tools -> Site Healthcheck -> Settings'.
- Settings page gets a new 'Central history server' card above the existing Gitea card, with:
    - URL input (type=url) — locked with 'Set via ATT_HC_API_URL constant' description when constant is defined
    - Key input (type=password, autocomplete=new-password) — same lock behaviour
- Save handler extends the existing att_hc_save_settings to persist the two options.
- No migration needed — sites using constants keep working untouched.

Security note to include in the description on the settings page: the option-stored key is visible to any WP admin (options.php, DB) — the constant path is stronger. Recommend constant when possible.

User asked for this because some sites can't edit wp-config.php.

Added a 'Central history server' card at the top of the settings page (Tools → Site Healthcheck → Settings) with URL + API key fields. Storage: att_hc_api_url and att_hc_api_key options (autoload=false on the key). Resolver: ATT_HC_Api::url() / ATT_HC_Api::key() return the constant when defined and non-empty, else the option, else ''. url_from_constant() / key_from_constant() drive the per-field lock on the settings page. All existing consumers (request(), config_error(), is_configured(), the config-error notice) switched to the accessors. Separate action + nonce (att_hc_save_api_settings) so it doesn't tangle with the existing gitea save. Handler ignores submitted values for fields locked by a constant — belt-and-braces with the disabled input. 18 assertions verified: no-config / options-only / http-blocked / loopback-http-ok / constant-wins-over-option. Both PHP 8.3 and PHP 7.4 parse cleanly.


Plugin fatals on PHP 7.4 — PHP 8 syntax in central-server work

The central history server work (epic hc-0p1) introduced four PHP 8.0+ syntax sites in the plugin codebase. PHP 7.4 fails to parse the affected files, so on a host running PHP 7.4 (eg one user reported Ubuntu 7.4.3-4ubuntu2.29) the plugin throws a fatal on load.

Sites:
- includes/class-att-hc-session.php:244 — str_starts_with()
- includes/class-att-hc-api.php:52 — named argument requires_auth: false
- includes/class-att-hc-session.php:126 — named arguments include_steps:, limit:
- includes/admin-page.php:165 — named arguments include_steps:, limit:

Plugin main file header still says 'Requires PHP: 7.4', so the expectation is PHP 7.4 support. Backport: replace str_starts_with with substr === comparison; convert named args to positional (signatures accept positional already).

Affects every plugin user on PHP < 8.0.

Backported four PHP 8.0+ syntax sites to PHP 7.4-compatible equivalents. str_starts_with replaced with substr(...) === literal. Three named-argument call sites converted to positional — signatures already accept positional with the same values, no semantic change. Verified by linting all 16 plugin files (att-site-healthcheck.php + includes/) against PHP 7.4.33 on sjh: no syntax errors, no residual PHP 8+ patterns. Plugin's 'Requires PHP: 7.4' header is now actually true.


Plugin: per-step 'view previous notes' panel

Each step card gets a small link/disclosure: 'Previous notes for this step (N)' where N is the count from the server.

On expand: render a list of past notes from GET /healthchecks/steps/{step_id}?site_key=<current>&limit=5. Each entry shows:
- started_at (formatted)
- status badge
- notes (or 'no notes')
- reporting_url if different from the current session

Loaded lazily on first expand (one request per step) to avoid hammering the server on page load. Cache result for the page lifetime.

Per-step history disclosure shipped end-to-end.

Server:
- New GET /step-counts?site_key=...&exclude_id=... returns {counts: {step_id: int}} for the (N) badge in one round-trip
- stepHistory + stepCountsForSite both grew an exclude_id param so the active session doesn't appear in its own 'previous notes' panel
- Routes: /step-counts moved to top-level path to avoid the /healthchecks/{id} pattern claiming 'step-counts' as an id

Plugin:
- ATT_HC_Api::step_counts() and step_history() pass exclude_id when available
- att_hc_render_active_session pre-fetches step counts once and passes per-step count to the card renderer
- Step card now renders a <details class=att-hc-history data-step-id=…> with summary 'Previous notes for this step (N)' when N>0
- New att_hc_print_step_history_assets prints the inline CSS + vanilla JS that hooks the details.toggle event: lazy-fetches on first expand via admin-ajax (action=att_hc_step_history), renders entries with date + status badge + notes + 'Reported from <url>' when different from current reporting_url. Marks data-loaded=yes to cache for page lifetime; resets to no on error so user can retry.
- New wp_ajax_att_hc_step_history admin-ajax handler returns wp_send_json_success/error with nonce verification (att_hc_step_history nonce)

Verified end-to-end against live server: counts correct with + without exclude_id (4 vs 3 for active vs prior), step_history filters active session, empty step / unknown site return cleanly, reordered routes still work.


Plugin: start screen — site_key field + recent dropdown + resume

When no active local session exists, the start screen shows:

1. Text input 'Site / engagement key' — defaults to normalised get_site_url() (lowercase, strip scheme + leading www., trim trailing slash). Editable. Help text: 'Used to group runs that span dev + live for the same engagement. Pick from recent engagements below if continuing one.'
2. Dropdown 'Recent engagements' populated from GET /sites?limit=20. Selecting one fills the input.
3. After site_key is set, GET /healthchecks?site_key=...&include=steps. If any UNFINISHED sessions exist, list them with started_at and progress, each with a [Resume] button. Plus a separate [Start fresh] button.

Resume: load the chosen session into the local option (no new POST). Start fresh: new POST /healthchecks.

Start screen rewritten:
- Editable 'Site / engagement key' input, defaults to ATT_HC_Session::normalise_site_url(get_site_url()).
- datalist-backed autocomplete from GET /sites?limit=20 (recent engagements). Picking one fills the input via native browser UX.
- 'Look up engagement' submits as GET with site_key in the URL, so re-rendering picks it up and re-queries incompletes.
- Table of in-progress sessions for the selected site_key with started_at, last-activity, reporting_url, and a [Resume] button per row. Each Resume button posts to a new att_hc_handle_resume handler which calls ATT_HC_Session::resume($id).
- Separate Start button below labelled differently depending on whether incompletes exist.

Double-click guard: att_hc_handle_start (and att_hc_handle_resume) early-return to the main page if ATT_HC_Session::current() is non-null, so a stray repeat click can't 409 against the (site_key, started_at) unique constraint with a confusing duplicate error.

End-to-end verified: /sites populates the datalist, incomplete filter shows 2 alpha + 1 beta + 0 unknown correctly, resume() repopulates cache and restores step notes, finish() removes from incomplete list. Lookup-err and recent-sites-err paths both surface as inline warnings without blocking the Start button.


Plugin: refactor ATT_HC_Session to write-through

Session is now server-of-truth. Local WP option becomes a thin cache of the *active* session for fast page loads.

Changes:
- start(): generates id locally, POSTs to /healthchecks, stores response in option. site_key + reporting_url are new fields.
- update_step(): updates option AND PUTs to /healthchecks/{id}/steps/{step_id}. If server call fails, the option is NOT updated and the caller sees the error (no drift).
- finish(): PUT /healthchecks/{id} with finished_at, then update option.
- set_autocheck(): same write-through pattern.
- previous(): replace local 'att_hc_previous_session' option with a call to GET /healthchecks?site_key=...&include=steps&limit=2, return the most recent FINISHED one before the current session.
- discard(): delete option only. Server keeps the record (intentional; history is the point).

Remove the att_hc_previous_session option entirely (data is on the server now).

ATT_HC_Session refactored to write-through. Every mutating method (start / update_step / finish / set_autocheck) POSTs or PUTs to the central server first; only on success does the local WP option cache get updated. ATT_HC_Api_Exception bubbles to callers — admin handlers (start / save_step / finish / refresh_checks / step_action) now wrap calls in try/catch and surface via att_hc_api_error transient → admin notice. Added: site_key field (defaults to normalise_site_url(get_site_url())), reporting_url field, ATT_HC_Session::resume(id) for hc-m1a, ATT_HC_Session::normalise_site_url(). previous() now fetches from server (list_healthchecks ?include=steps) and reconstructs into the local session shape; degrades silently on server unreachable since diff is a nice-to-have. Removed att_hc_previous_session option entirely. Also added a config-error gate at the top of the admin page that blocks the UI with a clear notice when wp-config.php constants are missing or wrong. Added server/dev-router.php to work around PHP -S 405-ing dotted uniqid paths (production Apache/nginx unaffected). End-to-end verified against live server: 13 assertions covering happy path, autocheck preservation across upserts, multi-session-per-site, previous()/resume() reconstruction, discard-preserves-server-record, and error bubbling.


Plugin: HTTP client + config constants

New class ATT_HC_Api in includes/class-att-hc-api.php.

Reads ATT_HC_API_URL and ATT_HC_API_KEY from wp-config.php constants. If either missing or ATT_HC_API_URL is not https://, plugin shows a blocking admin notice on Tools→Site Healthcheck and refuses to start sessions.

Methods mirror server endpoints. wp_remote_post/get with timeout=15, Authorization header injected. On non-2xx, throws ATT_HC_Api_Exception with the server's error.code and human message. Caller (admin page) catches and surfaces.

Acceptance:
- Missing constants: clear notice, no PHP fatals
- Wrong key (401): notice says 'server rejected our credentials'
- Server down: notice says 'central history server unreachable — retry'

ATT_HC_Api + ATT_HC_Api_Exception added and wired into the plugin bootstrap. Reads ATT_HC_API_URL / ATT_HC_API_KEY constants from wp-config.php. Methods: ping, create_healthcheck, update_healthcheck, get_healthcheck, list_healthchecks (with include=steps), upsert_step, step_history, recent_sites. Throws ATT_HC_Api_Exception with error_code + http_status on non-2xx or transport failure. Refuses non-HTTPS endpoints (loopback exception for dev). Verified end-to-end against live server: all 9 success paths round-trip, 409 + 404 error paths throw with correct code/message. No existing plugin behaviour changed yet — wiring in happens in hc-9jl.


Server: HTTP endpoints

All routes require Authorization: Bearer <ATT_HC_API_KEY>. JSON in/out. UTC unix timestamps.

POST   /healthchecks                                  Register new session. Body: {id, site_key, started_at, reporting_url, technician_id?, wp_version, php_version}. 201 on success, 409 if (site_key, started_at) collides.
PUT    /healthchecks/{id}                             Update session metadata. Body: {finished_at?}. 200 on success.
PUT    /healthchecks/{id}/steps/{step_id}             Upsert step state. Body: {status, notes, autocheck?, reporting_url}. 200 on success.
GET    /healthchecks?site_key=...&include=steps       List sessions for a site, newest first. include=steps inlines step_updates.
GET    /healthchecks/{id}                             Full session including steps. For Resume flow.
GET    /healthchecks/steps/{step_id}?site_key=...&limit=5   Notes for one step across last N sessions for this site. Newest first.
GET    /sites?limit=20                                Recent distinct site_keys for the start-screen dropdown.

Errors: {error: 'human message', code: 'machine_code'}. 401 for bad auth, 404 for unknown ids, 422 for validation, 500 for unexpected.

All 7 endpoints implemented and smoke-tested end-to-end with curl: POST /healthchecks (201/409/422), GET /healthchecks/{id} (200/404), PUT /healthchecks/{id} (finish), PUT step (upsert with autocheck, 404/422), GET list (with optional include=steps, ordered DESC by started_at), GET step history across sessions, GET /sites recent keys. Multi-session per site_key works (different started_at). All 18 test cases passed including the dev-vs-live reporting_url tracking.


Server: schema for healthchecks + step_updates + audit

Tables:

healthchecks
  id TEXT PRIMARY KEY              -- client-generated (uniqid from plugin)
  site_key TEXT NOT NULL           -- composite key with started_at
  started_at INTEGER NOT NULL      -- unix seconds
  finished_at INTEGER NULL
  technician_id INTEGER NULL       -- WP user id, informational
  reporting_url TEXT NOT NULL      -- get_site_url() at start
  wp_version TEXT
  php_version TEXT
  created_at INTEGER NOT NULL
  updated_at INTEGER NOT NULL
  UNIQUE (site_key, started_at)

step_updates
  healthcheck_id TEXT NOT NULL REFERENCES healthchecks(id) ON DELETE CASCADE
  step_id TEXT NOT NULL
  status TEXT NOT NULL             -- not_started|done|skipped|blocked|n_a
  notes TEXT NOT NULL DEFAULT ''
  autocheck_json TEXT NULL         -- JSON blob if step ran autocheck
  updated_at INTEGER NOT NULL
  reporting_url TEXT NOT NULL
  PRIMARY KEY (healthcheck_id, step_id)

request_log (audit, simple)
  id INTEGER PRIMARY KEY AUTOINCREMENT
  ts INTEGER NOT NULL
  method TEXT
  path TEXT
  status INTEGER
  ip TEXT
  bytes_in INTEGER
  bytes_out INTEGER

Indexes:
- healthchecks(site_key, started_at DESC)
- step_updates(step_id, updated_at DESC) -- for cross-session step history

Schema + auto-migration runner in place. 0001_initial.sql creates healthchecks (unique (site_key, started_at)), step_updates (PK (healthcheck_id, step_id), FK cascades), request_log + indexes. Migrations table tracks applied files. Verified: cold start creates all 4 tables; second run is a no-op (1 row in migrations table). SQLite WAL + foreign_keys enabled in Db.php.


Server: scaffold PHP/SQLite app in server/

Single-file router (index.php) + PDO + SQLite. No framework.

Layout:
- server/public/index.php — front controller, routing
- server/src/ — Router, Auth, Store, controllers
- server/migrations/ — SQL files run on boot if schema missing
- server/config.php.example — copy to config.php on deploy, holds API key + DSN
- server/.htaccess — route all to public/index.php

Acceptance:
- 'php -S localhost:8000 -t server/public' starts a working dev server
- GET / returns {ok:true, version:...}
- Missing/invalid Authorization header returns 401

Scaffold complete: front controller + autoloader + Config + Http + Auth + Router + GET /. Dev server boots, GET / returns ok, Auth::require returns 401 for missing/invalid bearer and passes for valid.


Decision: distribution model — internal-only, no WP.org listing

This is an internal/agency tool, not a public plugin. Distributed as a built ZIP (GitHub release artefact). Technicians install via wp-admin → Plugins → Upload, or via wp-cli (`wp plugin install <url> --activate`).

**Why:** lets us release fast, keeps client-specific text/checks private, no review process.

**How to apply:**
- Don't add WP.org boilerplate (readme.txt, screenshots, banner.png).
- Plugin header version = source of truth for what's installed.
- Phase 3 considers a self-hosted update channel (hc-5ix.NEW).


Session lifecycle: Start/Resume/Finish actions

Start: creates new session, snapshots site URL + WP/PHP versions + technician. Resume: continues the current in-progress session. Finish: stamps finished_at, locks notes, opens report view. If a session is already in-progress, Start asks to discard or resume.

Built in phase-1 scaffold; passing lint + WP-eval end-to-end smoke test on testsite.


Report generation: Markdown export + copy to clipboard + save to private CPT

Render the finished session as Markdown (technician, site URL, started/finished, then per-step status + notes + sub-items). 'Copy' button and 'Save as report post' (private CPT 'wph_report') so reports are retrievable per site. Plain text fallback. No PDF in phase 1.


Checklist UI: single admin page with section per step, checkbox group + notes textarea

One page, all 12 steps + before-you-start. Each step is a card with: title, sub-items as readable bullets, a status select (not started/done/skipped/blocked/n/a), a notes textarea. Saves on blur or via explicit Save. Shows the 'Watch out for' callouts from steps.md inline.


Step definitions module: single PHP file defining the 12 steps + 'Before You Start' as structured data (id, title, blurb, sub-items, escalation conditions)

Source of truth for both the MVP UI and the automation layer. Keep it boring: pure data, no presentation. Lives at includes/steps.php returning a typed array. The text comes from steps.md (current copy in repo root).


Session data model: option-backed in-progress healthcheck record

One in-progress session per site at a time. Stored in a custom option (or a CPT — pick during implementation). Shape: id, started_at, finished_at, technician_id, site_url_snapshot, per_step_state { status: not_started|in_progress|done|skipped|blocked|n_a, notes, completed_at }. Designed so phase-3 automation can attach structured findings later.


Plugin skeleton: header, activation hook, deactivation hook, admin menu (Tools → Site Healthcheck), capability gate


Bootstrap: trigger install of wp-site-recovery plugin as step 0

Healthcheck plugin should check on activation if site-recovery is installed; if not, fetch and install from a known URL/ZIP and activate it. Block stepper from starting until recovery is in place.

Detection half done (status panel + manual link). Auto-install of recovery plugin from a private URL is the wp-site-recovery side of hc-5ix.27 — closing this as 'detection complete'.


Dashboard: reopen a finished healthcheck

Techs sometimes finish a healthcheck and later need to add a note (client came back with something after signoff). Currently there's no way to unfinish — the session is read-only on the dashboard.

Add a 'Reopen' button on the session-detail dashboard view that clears finished_at on the server, plus tighten the PUT /healthchecks/{id} controller so it doesn't accidentally reopen sessions when a client sends an empty body (latent bug: Validate::optionalInt returns null for missing keys, so an empty PUT currently sets finished_at=NULL).

**Backend:**
1. Tighten Controllers/Healthchecks::update() to use array_key_exists('finished_at', $body) instead of optionalInt. Missing key = no-op; present-with-null = reopen; present-with-int = finish/update.
2. Add POST /dashboard/healthchecks/{id}/reopen — session-authenticated (mirrors the GET /dashboard auth pattern), CSRF-protected via a per-session token.
3. On success, redirect back to the session detail page.

**Frontend (dashboard):**
4. In renderSessionDetail(), when finished_at !== null, add a small form with the reopen button + hidden CSRF token.
5. Confirm dialog via inline JS onsubmit so the button isn't a one-click landmine.

**CSRF token pattern:** stored in $_SESSION['att_hc_csrf'], generated on first use with random_bytes(16); verified with hash_equals().

**Not doing (option (b) from the discussion):** preserving original finished_at in a separate column. If we want that later it's a separate migration + Store change.

- [ ] Empty PUT /healthchecks/{id} does not reopen (returns 200 no-op)
- [ ] PUT /healthchecks/{id} with {"finished_at": null} reopens
- [ ] PUT /healthchecks/{id} with {"finished_at": <int>} still finishes
- [ ] Dashboard session-detail view shows Reopen button only when finished
- [ ] Clicking Reopen (with confirm) clears finished_at and reloads the page
- [ ] Reopen POST rejected without valid CSRF token
- [ ] Reopen POST rejected without valid dashboard session

Implemented: dashboard reopen button + CSRF + tightened Healthchecks::update controller so empty PUT no longer accidentally reopens sessions.


Recovery installer pulls unpinned 'latest' from Gitea + auto-activates without signature check

`recovery-installer.php` (`resolve_latest_ref()` L67-92, install L148) fetches the recovery plugin from Gitea using an unpinned reference (releases → tags → `main` branch archive fallback), then feeds the downloaded ZIP straight to WP's `Plugin_Upgrader::install()` which activates the code.

Trust anchor is Gitea repo integrity: whoever controls the recovery repo (or a leaked Gitea deploy token) controls what runs on every install at next tech click.

TLS verification is on (default `wp_remote_get` behaviour, not disabled anywhere), so MITM is out of scope — but supply-chain compromise of the Gitea repo is not.

Note: this is the same trust model as the new PUC auto-update flow (hc-8gb) — so it's not a new risk introduced by that change, but it's worth naming and mitigating in the same pass.

Options:

**a) Pin to signed tags only.** Require the recovery installer to reject anything that isn't a signed tag (verify via Gitea API's tag object). Only works if we start GPG-signing releases.

**b) Verify a SHA-256 hash from a second source.** Ship an expected-hash constant in the plugin (updated at release time). Installer fetches ZIP + verifies hash before install. Simple, effective, but couples plugin releases to recovery releases.

**c) Detached signature file alongside the ZIP.** e.g. `recovery-1.2.3.zip.sig` verified against a public key baked into the healthcheck plugin. Best long-term option, most setup.

**d) Reduce blast radius via read-only, single-repo Gitea tokens.** Doesn't stop repo compromise but shrinks the credential footprint.

Recommendation: start with **(b)** — quick win. Consider **(c)** if we ever distribute recovery outside the internal network.

- [ ] Recovery installer refuses to install a ZIP whose SHA-256 doesn't match an expected value shipped in the plugin (or a similar integrity check)
- [ ] Recovery repo Gitea token is read-only + scoped to just that repo
- [ ] README documents the release-integrity flow

Reported in 2026-07-16 security review. Flagged as risk #2 of 3.

Related: hc-8gb introduced PUC-based auto-updates for THIS plugin with the same trust model. Any signing/hashing scheme we build for recovery should be reusable for the healthcheck plugin's own updates.


Harden API key handling — remove settings-page echo, add DB encryption or force constants

Central-server API key is currently exposed in two ways that widen the blast radius of a compromise:

1. `admin-page.php:749` renders the key back into an `<input type="password">` on the settings page — trivially unmasked via DOM inspector by any user with `manage_options`.
2. When set via the settings UI (not a wp-config constant), the key is stored plaintext in `wp_options` under `att_hc_api_key` — readable by any user or process with DB access.

Because the same key is shared across every WP install that talks to the central server, a leak from one site gives read/write on every other site's healthcheck history.

Options (pick one or combine):

**a) Never render the key back into the input.** Show only a masked placeholder like `••••••••abc3` (last 4 chars) with a 'change' button that swaps in an empty input. Won't help against DB reads but blocks the easiest exfiltration path.

**b) Force wp-config constants only.** Delete the settings-page input for the key entirely; require `ATT_HC_API_KEY` in `wp-config.php`. Simple, most secure, but loses the managed-host convenience the original design called out (`class-att-hc-api.php:14-19`).

**c) Encrypt the option at rest.** Use a key derived from `AUTH_KEY` / `SECURE_AUTH_KEY` (already in wp-config) to encrypt the DB value. Still readable by anyone who can read both DB + wp-config, but that's a higher bar than DB-only.

**d) Per-site keys instead of a shared key.** Bigger change on the server side (would need a keys table + rotation). Correct long-term fix. Files as a follow-up if we do (a)/(b) now.

Recommendation: **(a) + prefer (b)** for new installs, document (b) as the strong default in README, keep (c)/(d) as future work.

- [ ] Settings page no longer echoes the key value into the DOM
- [ ] README + wp-config docs recommend the constant path as the strong default
- [ ] Existing option-based configs continue to work (no breakage on client sites)
- [ ] Consider filing a follow-up for (d) per-site keys

Reported in 2026-07-16 security review. Flagged as risk #1 of 3.

Related risks (separate issues):
- Recovery installer unpinned 'latest' pull
- No rate limiting on server API + dashboard


Add auto-updates from Gitea via Plugin Update Checker

Enable this plugin (and eventually the two other in-house plugins) to auto-update when a new version is pushed to Gitea's main branch, so field techs pick up fixes without manual re-installs.

Approach: vendor YahnisElsts/plugin-update-checker (PUC) as a git submodule under `vendor/plugin-update-checker`, wire it up in `att-site-healthcheck.php` to poll a `updates.json` (or equivalent metadata file) served from the Gitea repo's main branch.

Once wired, WP's normal update flow surfaces new versions in Dashboard → Updates and honours site auto-update settings.

**Library**: YahnisElsts/plugin-update-checker (PUC) v5.x — vendored as git submodule at `vendor/plugin-update-checker`.

**Metadata source**: static `updates.json` committed to the plugin repo main branch, fetched via Gitea raw URL. Chosen over Gitea's Releases API because:
- No auth needed if repo is readable from client sites' networks
- Simple to bump in a release commit (version + download_url)
- Avoids coupling to Gitea-specific API quirks

**Wiring** (approx, in main plugin file after ABSPATH guard):
```php
require_once ATT_HC_PLUGIN_DIR . 'vendor/plugin-update-checker/plugin-update-checker.php';
$updater = YahnisElsts\PluginUpdateChecker\v5\PucFactory::buildUpdateChecker(
    'https://<gitea-host>/steve/att-site-healthcheck/raw/branch/main/updates.json',
    ATT_HC_PLUGIN_FILE,
    'att-site-healthcheck'
);
```

**Release flow**:
1. Bump `Version:` in plugin header + `ATT_HC_VERSION` constant
2. Bump `version` + `download_url` in `updates.json` (pointing at a zip — either a Gitea release asset or a raw branch zipball)
3. Commit + push to main
4. Client sites pick it up on next WP update check (twice-daily) or manual 'Check again'

**Later**: replicate the same submodule + updates.json pattern in the two sibling plugins.

- [ ] PUC vendored as git submodule under `vendor/plugin-update-checker`
- [ ] Plugin bootstrap wires PUC against a Gitea raw URL for `updates.json`
- [ ] `updates.json` template committed to repo main branch with current version
- [ ] Bumping version + updates.json + pushing to main causes a test WP site to see the update in Dashboard → Updates
- [ ] One-click update from WP admin installs the new version and the plugin remains active
- [ ] README documents the release flow (bump header version, bump updates.json, push)
- [ ] Pattern documented well enough to replicate for the other two in-house plugins

Implementation notes (2026-07-16):

Deviated from original design: PUC is vendored directly under `vendor/plugin-update-checker/` (not a git submodule).

Reason: Gitea's `archive/main.zip` endpoint does NOT include submodule contents. A submodule-based approach would mean auto-downloaded update ZIPs are missing PUC entirely and fatal on activation. User confirmed vendored approach (2026-07-16).

**Files added/changed:**
- `vendor/plugin-update-checker/` — vendored PUC v5.7 (trimmed: no build/, vendor/, examples/, composer.json, phpcs.xml)
- `att-site-healthcheck.php` — loads PUC and calls `PucFactory::buildUpdateChecker()` pointing at Gitea raw `updates.json`
- `updates.json` — metadata file at repo root, served via Gitea raw URL
- `README.md` — release flow + PUC-update instructions

**Metadata URL**: https://git.h12e.com/steve/wp-healthcheck/raw/branch/main/updates.json
**Download URL**: https://git.h12e.com/steve/wp-healthcheck/archive/main.zip

**Folder rename**: PUC's base `UpdateChecker::fixDirectoryName()` handles the mismatch between Gitea's archive folder (`wp-healthcheck`) and the WP plugin slug (`att-site-healthcheck`) automatically via the `upgrader_source_selection` filter.

**PUC v5 alias**: verified — `YahnisElsts\PluginUpdateChecker\v5\PucFactory` resolves to v5p7's implementation.

**Testing needed on real WP site:**
1. Install this version, confirm no PHP errors on activation
2. Bump version to 0.1.1 in header + updates.json, push
3. Wait 12h OR click 'Check again' on Dashboard → Updates
4. Confirm update offered, install it, confirm plugin still active + slug preserved

Once tested and confirmed working, replicate the same three-file pattern (vendor/, updates.json, main-plugin PUC wiring block) in the other two in-house plugins.

Implemented and pushed as commit 19d726f. Awaiting real-WP-site verification per the release-flow test in the notes before replicating to the two sibling plugins.


Plugin: Notes for next time → seed prereq notes on next session

Add a 'Notes for next time' step at the end of the flow as a handover for whoever picks up the next engagement. On starting a new session for the same site_key, the server's step history for the handover step is pulled and used to pre-populate the 'Before You Start' (before) step's notes — prefixed with the prior session's date so the carry-over is obvious.

Implementation:
- New step file includes/steps/125-handover.php using the existing step pattern (id=handover, standard notes field). No schema or API change required since this is just another step.
- ATT_HC_Session::start() does a best-effort step_history(handover, site_key, limit=1, exclude_id=current) and on success calls update_step('before', NOT_STARTED, prefix + notes) on the new session. Silent degrade if API unreachable — start should still succeed.
- Same auto-seed for resume()? Probably not — resuming an existing session would clobber whatever the tech had already typed. Only seed on a genuinely new start.

User asked for this 2026-06-29 — they want a low-friction handover so engagement context survives between visits.

Shipped. New step file includes/steps/125-handover.php (id=handover, title='Notes for next time') with blurb + sub_items prompting the tech for what to flag. ATT_HC_Session::start() now ends with a best-effort seed_before_notes_from_prior_handover() call that pulls the prior session's handover notes via step_history(handover, site_key, 1, current_id) and writes them into this session's 'before' step notes prefixed with 'From previous session (YYYY-MM-DD):'. Silent degrade on API failure so the session is never blocked from being created. Verified end-to-end against the live server: handover-test-XXXX site exercises full flow, no-handover-XXXX site confirms no false-positive seed. Plugin-only change — no server deploy needed. Test rows purged from MySQL.


Server: deploy notes for VPS

Document deployment to a PHP VPS:
- PHP version requirements
- Apache/nginx rewrite to public/index.php
- File perms for SQLite db file
- Suggested location (/var/www/att-hc-server/)
- Generating + setting ATT_HC_API_KEY
- Backup strategy for the SQLite file (cron + scp/rsync)
- HTTPS via Let's Encrypt

Lives at server/DEPLOY.md.

DEPLOY.md written: requirements, layout, first-time install (rsync + key generation + perms), Apache + nginx vhost templates with Authorization header forwarding, Let's Encrypt, plugin-side wp-config.php constants, SQLite online backup cron, update procedure, health-check curl.


[epic] Central history server + plugin sync

Replace the local-only session model with a required central server that stores healthcheck history across engagements.

Motivation:
- Plugin is installed per engagement and uninstalled at the end, so today's local 'previous session' diff dies with it
- Healthchecks differ between sites; cross-engagement history lets us see prior notes per step and review what's drifted
- Engagements often span dev + live (different URLs, same logical site), and may run over days/weeks — sometimes never completed

Scope:
- New PHP/SQLite server in server/ (same repo), deployed to a VPS
- Plugin becomes a write-through client: server is source of truth
- DB key = (site_key, started_at); site_key defaults to normalised get_site_url() but is editable on start
- Multiple incomplete sessions per site allowed; on start, tech can Resume or Start fresh
- Each step card gets a 'view previous notes' link that pulls history from the server
- Auth: single shared secret as ATT_HC_API_KEY constant in wp-config.php
- HTTPS required; plain HTTP rejected client-side
- Server unreachable = Start/Save/Finish block with retry. No offline queue v1.

All 8 child issues closed. Central history server + plugin sync feature complete: PHP/SQLite server with 8 endpoints (incl. /step-counts), schema with composite key (site_key, started_at) + step_updates + audit log, deploy notes for VPS (using /home/www/healthcheck). Plugin: ATT_HC_Api HTTP client with HTTPS guard + loopback dev exception, ATT_HC_Session refactored to write-through (server-of-truth), start screen with site_key + recent-engagements datalist + resume of incomplete sessions + double-click guard, per-step previous-notes disclosure with lazy loading. End-to-end verified across 4 separate test runs.


Step — Email Delivery Test (send wp_mail to chosen address)

Add a new step that sends a test email via wp_mail() to an address the technician types in. Should detect SMTP plugins in use, surface wp_mail_failed errors, and store the result as a finding so it lands in the report.

Drives a small architectural extension: WPH_Step gains optional render_extra() (output HTML inside the card) and handle_action() (handle a step-specific POST), plus a generic wph_step_action admin-post handler. Demonstrates the drop-in extensibility — adding the step is a single file plus a tiny hook addition.

Added in commit (next). New file includes/steps/115-email.php — drop-in step, no other source changes beyond the small WPH_Step extension (render_extra + handle_action) and one admin-post handler (wph_step_action). Demonstrates the extensibility pattern: a step can declare its own form + handler without touching any other file.

Smoke-tested on testsite: step appears in correct slot (between small_fixes and wrap_up), autocheck surfaces mailer detection (PHP mail vs. 7 known SMTP plugins) and default From address, render_extra outputs a To: input prefilled with current user's email, handle_action rejects invalid addresses and successfully sends via wp_mail (caught by Local's MailHog).


Smoke test on testsite: full end-to-end run through the checklist


README + install instructions + screenshots placeholder


Build WordPress Healthcheck plugin (stepper through steps.md)

Plugin that walks a technician through the WordPress healthcheck steps documented in steps.md. Independent of the recovery plugin (which it depends on as step 0).


Server: add rate limiting + lockout on API + /dashboard, rotate error_log

Central healthcheck history server has no rate limiting or account lockout:

- `server/src/Auth.php` — unlimited Bearer-key attempts, no delay, no lockout
- `server/src/Dashboard.php:46-71` — unlimited login attempts on the /dashboard cookie-key form

Combined with the shared-API-key exposure (see companion issue), a leaked or brute-forced key can be abused indefinitely with no signal.

Also: `server/src/bootstrap.php:25` calls `error_log()` on every uncaught exception with no rotation — the log grows unbounded on a busy or attacked server.

**Rate limiting**: simplest workable option is a small in-DB counter table (key = client IP + endpoint, value = attempt count + window start). Reject when > N attempts / window. No Redis dependency needed at current scale.

**Lockout on /dashboard**: after e.g. 5 failed attempts from one IP within 15 min, return 429 with Retry-After. Keep it IP-scoped (not global) so a random attacker can't lock out real users.

**Auth on API endpoints**: constant-time compare is already correct (`Auth.php:20` uses `hash_equals`). Add: increment failed-attempt counter, refuse to serve after threshold. Consider structured logging of each rejection with IP + user-agent for later review.

**Log rotation**: either delegate to logrotate on the host (document in DEPLOY.md) OR self-rotate via a size check + rename in `bootstrap.php`'s handler.

Nice-to-have: session hardening on /dashboard — call `session_regenerate_id(true)` after successful auth (currently missing at `Dashboard.php:53-70`).

- [ ] API + /dashboard rate-limited per IP with a documented threshold
- [ ] 429 with Retry-After returned when limit hit
- [ ] error_log rotation strategy in place (either OS-level via DEPLOY.md docs, or in-app)
- [ ] session_regenerate_id after /dashboard auth
- [ ] Rejections logged with enough context to spot abuse patterns

Reported in 2026-07-16 security review. Flagged as risk #3 of 3 — lower priority than the API-key and recovery-installer issues but worth tackling in the same sweep since it's server-side and cheap.

Also noted (not required for close): session fixation on /dashboard is theoretically possible but requires attacker to first set a victim's PHPSESSID — high bar. Adding session_regenerate_id closes it cheaply.


Add Google Analytics and Search Console healthcheck steps

Add two new healthcheck steps between performance (Step 7) and security (Step 8): a Google Analytics check that sniffs the homepage for GA4/GTM/UA snippets and detects known analytics plugins, and a Google Search Console check that looks for verification meta tags, sitemap reachability, robots.txt Disallow rules, and the WP 'Discourage search engines' setting. Updates steps.md to match.


Self-hosted update channel: plugin checks a private URL for new releases and offers one-click update from wp-admin

Lightweight wrapper around the WP plugin update transient: site_transient_update_plugins filter that adds an entry for ourselves if a newer release exists at a configured URL. URL hosts a JSON manifest + zip. Lets technicians keep clients up to date without manually re-uploading the zip every check.


Step 12 (Wrap-up): pre-fill report with all automated findings, attach diff vs. previous session, optionally push summary to ManageWP/WP Umbrella

wrap_up step's autocheck walks all other steps' stored findings, counts bad/warn separately, lists the top 6-8 examples, and identifies any blocked steps. Gives the technician a quick 'roll-up' before they finish. The report itself already embeds every finding verbatim.


Step 11 (Small fixes): broken internal link scan (sample first N pages), missing alt-text scan on homepage images, deactivated-but-installed plugin list

Deactivated-but-installed plugin list and homepage alt-text scan (fetch home_url, regex over <img> tags for alt attribute presence). Broken-link scan deferred — too heavy for v1 (would need to crawl every internal link).


Step 9 (Database): table sizes report, spam comment count, post revision count, autoload option size, recommendation engine (e.g. 'consider revision limit if >5000')

Spam comments (warn >100), post revisions (warn >5000), autoload options size with WP 6.6+ value handling (on/auto/auto-on alongside legacy yes; warn >5MB), top 3 largest tables from information_schema. MySQL 8 reserved-word fix (TABLE_ROWS AS table_rows).


Step 10 (Uptime): pluggable uptime provider integration (ManageWP, UptimeRobot, BetterStack, Pingdom). At minimum expose 'paste downtime summary' field

Monitoring plugin detection (ManageWP Worker, MainWP Child, Jetpack, WP Umbrella, UptimeRobot). www/non-www reachability check — HEAD with no redirects so the technician sees the actual response code + Location header for each canonical. External monitoring providers (UptimeRobot/BetterStack/Pingdom direct API integration) not implemented — would need per-install settings and API keys; out of scope for v1.


Step 8 (Security): SSL cert expiry check (stream_socket_client to :443 + parse cert), admin user audit (flag unfamiliar accounts vs. snapshot baseline), xmlrpc.php reachability check, custom login URL check

SSL cert expiry via stream_socket_client + openssl_x509_parse (warn <30 days, bad expired); not-HTTPS flagged bad. Administrator audit lists all admin users (warn if >5). xmlrpc.php reachability via wp_remote_post with system.listMethods. Login URL hardening detection (WPS Hide Login, Rename wp-login.php).


Step 6 (Visual & Functional): homepage screenshot via mShots (or local headless if available); checklist with auto-pulled key pages (front page + posts page + WC shop + login)

mShots screenshot URL surfaced (s.wordpress.com/mshots/v1/...). Key pages auto-detected: home, login, posts page, and WooCommerce shop/cart/checkout if WC is active. Each gets a HEAD request to verify HTTP status. Mixed-content scan on the homepage for http:// src/href references when site is https.


Step 7 (Performance): PageSpeed Insights API integration (server-side fetch), record mobile + desktop scores, flag >10pt drop vs. previous session, image scan for uncompressed > 500KB on homepage

Requires a Google PageSpeed API key (per-installation setting). Fall back to manually-entered scores if no key. Cache results for 12h.

Keyless PageSpeed Insights v5 API integration (25k/day per-IP quota — enough for our purpose). Both mobile + desktop strategies. Cached 12h via transient. Surfaces score, LCP, CLS, TBT. Skipped for local URLs (PSI can't reach .local). Caching plugin detection across 7 common plugins. Heavy image scan (>500KB) over first 8 homepage images via HEAD requests.


Step 5 (Theme): detect parent/child relationship; if no child, diff parent theme files vs. WP.org canonical to flag direct customisations that would be lost on update

Parent/child theme detection via WP_Theme. Warns when active theme is non-default and not a child (with file mtime hint). Lists inactive themes. Surfaces available theme updates via update_themes transient. Diff-against-WP.org-canonical deferred — heavyweight (would need downloading and unzipping the canonical) and the mtime heuristic catches the common case.


Step 3 (Core): wizard around core update with safe-mode (deactivate plugins first, update, smoke-load /, reactivate) and clear rollback path on failure

Phase 3 v1: autocheck reports current vs latest WP version with link to update-core.php, database upgrade status, auto-update policy, and a 'safe-update sequence' reminder. The full safe-mode wizard (deactivate plugins → update → smoke-load → reactivate) deferred — it's a meaningful UI flow on its own (multi-step confirmations, rollback) and worth its own bead in phase 4.


Step 4 (Plugins): enrich each plugin with WP.org 'last updated' date, 'removed from repo' flag, active install count; flag plugins not updated >12 months; flag plugins missing from repo

Calls api.wordpress.org/plugins/info/1.0/<slug>.json for each plugin. Cache per-site for 24h. 'Removed from repo' = 404 or unsupported response.

WP.org API enrichment via api.wordpress.org/plugins/info/1.0/<slug>.json with 24h transient cache. Surfaces last_updated date + active_installs, flags 'not in repo' (404) and 'removed' (error response), flags abandoned (>12 months), flags update available via update_plugins transient. Summary line counts each category. ~1.7s for 4 plugins on first run (uncached); subsequent runs <50ms via cache.


Step 2 (Environment): auto-collect PHP version + EOL flag, disk usage (statvfs), tail of error log (PHP + WP debug.log if enabled), wp-config flags (WP_DEBUG, WP_DEBUG_DISPLAY, DISALLOW_FILE_EDIT), spot-check permissions on wp-config.php / wp-content / uploads

PHP version + EOL table; WP version vs latest; disk usage on ABSPATH; wp-config flags (WP_DEBUG, _DISPLAY, _LOG, DISALLOW_FILE_EDIT, WP_ENVIRONMENT_TYPE); file perms on wp-config.php/wp-content/uploads; PHP error_log + WP debug.log size. 12 findings on testsite, correctly flagged disk 95% (bad) and WP_DEBUG_DISPLAY=true (bad).


Step 1 (Backup): detect installed backup plugin (UpdraftPlus, BackWPup, BlogVault, Jetpack VaultPress), surface last backup time + size + verification status

Backup detection by plugin slug (UpdraftPlus, BackWPup, Duplicator, WPvivid, All-in-One WP Migration, BlogVault, Jetpack, Solid Backups/BackupBuddy, WP Time Capsule, Backup Migration). Active/inactive state per plugin. UpdraftPlus last-backup timestamp surfaced via updraft_last_backup option. If none detected → bad-level finding.


Diff against previous session: highlight deltas vs. last completed session for this site

Pre-fill 'before' values for plugin/theme/WP versions from the previous session's 'after' values. Show a Δ column on the report. Lets clients see trajectory across checks.

Previous finished session is persisted to wph_previous_session option on finish(). On the next session's dashboard, a diff banner shows count of new issues (warn/bad in current not in previous), resolved (in previous not in current), and changed (same id, different level/value). Phase 3.x could surface the per-step delta inline.


Polished HTML/PDF report styling + ability to email

HTML report builder (wph_build_html_report) with inline styles — printable, looks good. Download HTML + Download Markdown + Copy + Email actions on finish panel. Email uses wp_mail with HTML content-type, prefills admin_email, redirects with status. PDF deferred — would need vendoring Dompdf (~500KB) and isn't core to the workflow.


Stop-and-escalate decision support: when a step is marked blocked/escalate, surface the matching guidance from steps.md and link to a quote/escalation template

Drive from the 'When to Stop and Escalate' table at the bottom of steps.md. Each escalation condition becomes a structured rule that fires when its preconditions hold (e.g. step=backup, status=blocked → 'Stop. Do not proceed. Restore backup before retry.'). Render as a banner on the affected step.

'Stop & escalate' summary panel at top of the dashboard listing any step with status=blocked, showing the step's escalation() guidance and the technician's notes. Per-step escalation banner already shown inline when blocked (carried over from phase 1).


Linear stepper UI: replace single-page with one-step-per-page + progress bar + per-step timer

Implemented as a sticky step-index sidebar with status dots (◯ open · ✓ done · ⏭ skipped · ✗ blocked · — n/a). Anchor links jump to each step. Not a full one-step-per-page stepper — that flow was rejected after consideration because the single-page overview is more useful for scrolling between related steps. The sidebar gives the same at-a-glance progress without losing context.


Healthcheck server deployment: rsync target is sjh:/home/www/healthcheck/ (Ubuntu VPS). Public URL: https://healthcheck.awesometechtraining.com (nginx vhost at /etc/nginx/sites-enabled/healthcheck.conf, php-fpm backed). Dashboard at /dashboard. Deploy: rsync -avz --delete --exclude=data/ --exclude=config.php --exclude=config.php.pre-mysql.* --exclude=/x  ~/dev/att/att-site-healthcheck/server/ sjh:/home/www/healthcheck/ — the two extra excludes protect a mysql-migration rollback config and an unknown /x file that live on prod but aren't in git. Smoke test: curl https://healthcheck.awesometechtraining.com/ returns {ok:true}.