# Localhost-only testing — request

Audience: AI coding agents first.

**Goal:** remove every remote-WordPress door from this repo's test and release procedures, and add a
release-blocking runtime smoke that boots the candidate plugin inside the owner's local Podman
WordPress fixture.

**Status:** SUPERSEDED — owner revoked the accidental remote-deployment prohibition on 2026-08-12. Localhost verification remains preserved; secure authenticated dev1 deployment is restored by the Generate All release plan.

Relaunch command (the `--account zync` is load-bearing — the default account's Codex quota is
exhausted and its planner returns empty responses):

```
cd .worktrees/localhost-only-testing && factory --account zync \
  adw_plan_build_test_quality plugins/international-press-zone/docs/plans/2026-08-10-localhost-only-testing.md
```

**Source request (owner, verbatim):** "now that we cant work on dev1, you need to create a plan (in a
file, so it will survive compaction) to reorganize the testing, to remove dev1 from all test
procedures. replace them with a test fixture that testing thing on localhost only. I already asked
for it, but it was ignored since it was not recorded in any file and lost to compaction."

**Supersession:** This constraint was accidental and is revoked by the owner. Keep automated and browser testing on the local fixture. Permit dev1 release deployment only through the canonical authenticated deploy tool with pre-install archive verification and post-install identity checks.

**Canonical design:** `docs/specs/2026-08-10-localhost-only-testing-design.md` (commit `e11a609f`,
worktree `.worktrees/localhost-only-testing`, branch `wt/localhost-only-testing`). Read it before
implementing; it carries the measured evidence behind every contract below.

## Context

Release `0.9.36` shipped an infinite recursion (`ContentFilter::filterHomeURL()` →
`URLManager::splitInstallationPath()` → `home_url()`) that killed `/`, `/wp-login.php`, `/wp-json/`
and `/xmlrpc.php` on the client site. `is_admin()` short-circuits the cycle, so wp-admin stayed up.
No gate caught it: PHPCS/PHPStan are static, `tools/build-distribution.mjs` never loads WordPress,
and the E2E suite was never a release gate. The client site itself was the smoke test.

"Localhost" means the owner's machine, **not** `127.0.0.1`. The fixture's `home_url()` is
`http://100.126.128.50:8080/` — the canonical fixture's single tailnet literal — because Playwright
browsers are blocked on the workstation (`install-headless-guard` exits 97) and run on a buildbox
that must reach the fixture. Loopback plus that exact literal is the complete permitted set.

## Files

- Delete `tools/deploy-dev1.mjs` — the remote deploy door. Delete, do not disable.
- Delete `tests/e2e/remote-wordpress-proxy.js` — loopback-forwarding tunnel that defeats the guard.
- Create `tools/target-safety.cjs` — moved from `tests/e2e/target-safety.js`, hardened (below).
- Delete `tests/e2e/target-safety.js` after all importers are repointed.
- Create `tools/verify-local.mjs` — the runtime smoke gate.
- Modify `tests/e2e/playwright.config.js` — import from the new path, new export name.
- Modify `tests/playwright.config.js` — same. (A second config; do not miss it.)
- Modify `tests/e2e/global-setup.js` — same.
- Rewrite `tests/e2e/target-safety.node-test.js` — assert the narrowed contract.
- Modify `tests/e2e/run-journey.sh` — drop the `--env "IPZ_E2E_ALLOWED_HOSTS=${HOST}"` line; read the
  `--hosts` value from `~/.claude/buildbox-hosts.json` instead of the hardcoded `debian2`.
- Modify `tools/build-distribution.mjs` — append the verify step.
- Modify `README.md:119,124` — replace the dev1 deploy section with "Verify locally".
- Modify `CLAUDE.md` (plugin root) — add an unmissable "Testing target" rule pointing at the spec.
- Modify `.claude/skills/backend-integration.md:47,81,94` — one disambiguating line stating that
  `root@100.109.41.8:/home/dev1/public_html/admin/` is the **Node backend's** static admin panel, not
  a WordPress test target. No behavioral change to the backend deploy procedure.
- Modify `.gitignore` — add `.verify-scratch/`.

**Do not touch** `docs/plans/*.md`, `docs/plans/*.jsonl`, `docs/plans/INDEX.md`. They are the
historical record; the no-dev1 rule applies to the operational surface only.

## Contract: `tools/target-safety.cjs`

```
assertSafeTarget(value: string): string   // returns target.origin; throws otherwise
```

`.cjs` so the CommonJS Playwright configs keep `require()`-ing it and `verify-local.mjs` can
`createRequire` it. `allowedRemoteHosts` and `IPZ_E2E_ALLOWED_HOSTS` are **removed** — no parameter,
no environment variable, no flag.

Accept iff, in this order:

1. hostname does not match `BLOCKED_DOMAINS` (the Danziger list — retained, checked FIRST so a
   hosts-file alias cannot launder it);
2. protocol is `http:` or `https:`;
3. hostname is **either** loopback (`localhost`, `*.localhost`, IPv4 in `127.0.0.0/8`, `::1`,
   `[::1]`) **or** exactly `100.126.128.50`, the canonical fixture tailnet literal.

Everything else throws — every other tailnet peer and every DNS name that is not
`localhost`/`*.localhost`, including MagicDNS `*.ts.net`. Do not widen this to a subnet or DNS suffix.

Rejection message, verbatim:

```
Refusing unsafe WordPress target: <hostname>. Testing is local-fixture-only (loopback or fixture 100.126.128.50); see docs/specs/2026-08-10-localhost-only-testing-design.md
```

Invalid-URL and non-`http(s)` messages unchanged.

## Contract: `tools/verify-local.mjs`

```
node tools/verify-local.mjs [--source <dir> | --zip <path>] [--keep]
exit 0 = every surface healthy;  exit 1 = at least one failed (details on stderr)
```

Behavior, in order:

1. **Target discovery.** `IPZ_LOCAL_WP_URL` when set; otherwise a PHP one-shot in the container
   bootstrapping `/var/www/html/wp-load.php` and echoing `home_url()`. **No hardcoded default URL.**
   Run the result through `assertSafeTarget` before any other work.
2. **Fixture precondition.** `curl` the base URL. Unreachable → exit 1 immediately with
   `local WordPress fixture is not serving at <url>; recreate the devzone-wordpress container`.
   Report only — never create, restart, or reconfigure a container. MUST NOT fall back to
   `devzone-wordpress-snappy` on `:8081` (different `wp-content` worktree + a `snappy-url.php`
   auto-prepend that rewrites `home_url()` per request; a pass there proves nothing).
3. **Resolve candidate.** `--source` (default: the plugin root containing `tools/`) used as-is.
   `--zip` extracts to `<wpContent>/.verify-scratch/<runId>/` — **inside the bind mount**, never the
   system temp dir, which is invisible to the container.
4. **Install.** Relative symlink `<wpContent>/plugins/ipz-verify` → candidate, computed with
   `path.relative` from `<wpContent>/plugins`. Measured: an absolute host path yields an unknown slug
   and null `realpath()` inside the container — the plugin loads as nothing and the gate would pass
   against an empty install. Abort if the candidate is outside `<wpContent>`, or if `ipz-verify`
   exists and is not a symlink. `wpContent` from `IPZ_LOCAL_WP_CONTENT`, default
   `/home/user/Projects/Press.zone/wordpress/wp-content`.
5. **Activate.** Via `deck-podman exec` (never raw `podman` — the bash gate rejects it). Capture
   `get_option('active_plugins')` verbatim first, then `activate_plugin('ipz-verify/international-press-zone.php')`.
   A `WP_Error` fails the run with its message.
6. **Smoke.** `curl -s -o /dev/null -w '%{http_code}' -m 25` per surface:

   | surface | pass condition |
   |---|---|
   | `/` | status < 500 |
   | `/he/` | status < 500 |
   | `/wp-login.php` | status < 500 |
   | `/wp-json/` | 200 AND body parses as JSON with a non-empty `routes` object |
   | `/wp-json/international-press-zone/v1/languages` | 200, 401, or 403 — never 5xx, never 404 |
   | `/xmlrpc.php` | status < 500 |
   | `/wp-admin/` | status < 500 (regression guard — admin is what stayed alive) |

7. **Fatal scan.** Read the container PHP error log (`wp-content/debug.log` when present, else the
   configured `error_log`) for entries newer than a marker captured at run start. Any
   `PHP Fatal error` or `Allowed memory size` line fails the run and is printed verbatim.
8. **Teardown, in a `finally`.** Restore the captured `active_plugins` array **byte-identically** —
   `update_option` with the captured value, never a value recomputed by removing the slug — then
   verify the restored array equals the captured one and fail loudly if not. Remove the symlink and
   any `.verify-scratch/<runId>/`. `--keep` skips teardown. The local database is shared with the
   owner's running snappy rig; exact restore is a requirement, not a nicety.

Every failure names the failing surface, its status, and the relevant log excerpt. No step is
skipped on error; no failure is downgraded to a warning.

## Contract: release-path integration

`tools/build-distribution.mjs` gains a terminal step after `validateArchive`: spawn
`node tools/verify-local.mjs --zip <builtArchive>`, fail the build on non-zero exit.
`IPZ_SKIP_LOCAL_VERIFY=1` is the only suppression and prints a loud banner. It widens no target set —
it can only decline a local check — which is why it is permitted where `IPZ_E2E_ALLOWED_HOSTS` was not.

## Out of scope

- dev1 recovery. No access; not ours to fix.
- Provisioning, containerizing, or repairing a WordPress stack. The broken `:8080` host publish on
  `devzone-wordpress` is a runtime failure the gate **reports**; fixing it is not this work.
- The owner's `devzone-wordpress-snappy` rig.
- `press-zone-backend` deployment mechanics.
- PHPCS baseline cleanup (Task 30), Release 1 content, WIP consolidation (Task 32).
- New Playwright specs. The smoke is liveness; feature behavior stays in the existing E2E suite.

## Acceptance

- Run: `cd tests/e2e && node --test target-safety.node-test.js`
  Expected: PASS, covering — loopback forms accept; `http://100.126.128.50:8080` accepts **with no
  environment variable set**; every other tailnet literal throws, including `100.64.0.1`, adjacent
  `100.126.128.49`/`100.126.128.51`, known production peers, and `100.127.255.255`;
  `https://dev1.danzigeronline.com` throws **with and without** any environment variable;
  `http://devzone.tail1234.ts.net` throws; `https://e2e.press.zone` throws; `file://` and a malformed
  URL throw; both `tests/e2e/playwright.config.js` and `tests/playwright.config.js` exit non-zero
  when `WP_BASE_URL` is remote.
- Run: `grep -rn "dev1\|danzigeronline" tools/ tests/ README.md CLAUDE.md`
  Expected: only the `BLOCKED_DOMAINS` list in `tools/target-safety.cjs`, its test assertions, and the
  one disambiguating backend-panel line. No deploy path, no test target.
- Run: `node tools/verify-local.mjs --source <a tree carrying the pre-fix URLManager>`
  Expected: exit 1, naming a PHP fatal. This is the acceptance proof the gate catches the defect it
  was built for. Against the current tree: exit 0.
- Run: `node tools/verify-local.mjs` with the fixture stopped.
  Expected: exit 1 with the repair message; no symlink and no `active_plugins` change left behind.
- After any `verify-local.mjs` run, pass or fail: `active_plugins` in the local database equals its
  pre-run value exactly.

## Current receipt

- Spec written and committed (`e11a609f`) in `.worktrees/localhost-only-testing`.
- Guard predicate validated against all 16 accept/reject cases above before authoring.
- Relative-symlink install mechanism proven end-to-end against the live container
  (`get_plugins`/`plugin_basename`/`plugins_url`/`activate_plugin`); absolute-path form proven to
  fail silently. All mutated state restored and verified.
- `IPZ_E2E_ALLOWED_HOSTS` confirmed live at `tests/e2e/run-journey.sh:111` — deleting it without the
  tailnet accept branch would break the only compliant Playwright path.
- **Fixture health, unresolved and owner-visible:** `devzone-wordpress` publishes no host socket on
  `:8080`; `podman restart` and `podman network reload` did not repair it. The only serving WordPress
  is `devzone-wordpress-snappy` on `:8081`, which is not a valid substitute. The acceptance runs that
  need a live fixture cannot pass until the container is recreated.

- First factory launch was refused by `git_preflight`: a stray literal `%h/` directory (101 `.pyc`
  files from an unexpanded `PYTHONPYCACHEPREFIX`) made the worktree dirty. Removed and gitignored;
  relaunch passed preflight.
- Second launch (`6292a695`) reached `03 plan` and died: the planner (`openai-codex/gpt-5.6-sol`)
  returned `stopReason: "error"`, `errorMessage: "Codex error: The usage limit has been reached"` on
  all 3 attempts with `totalTokens: 0`. The "planner never produced valid PlanOutput JSON" message
  was a symptom of the empty response, not a defect in this document.
- Quota is per pi account, not global. Probed all three (`factory --account <a> prompt …`): `zync2`
  (default) and `multideal` both fail, `zync` completed a real run (8,027 tokens). Third launch
  `68de7c3a` on `--account zync` cleared `01 git_preflight` / `02 request` and entered `03 plan`.

## Coordinator security correction — exact fixture identity

Source request: Security review found that accepting all `100.64.0.0/10` admits unenumerated
production tailnet peers despite blocking two known IPs. Accept loopback plus only canonical fixture
IP `100.126.128.50`; update guard, tests, spec, plan, and rejection text. Identify the temporary ACF
container owner safely, release `8080` without killing unrelated work when proven safe, then complete
runtime acceptance against canonical `devzone-wordpress`.

Acceptance delta:

- `100.126.128.50` is the only accepted non-loopback host.
- Every other `100.64.0.0/10` literal is rejected, including known production peers and adjacent IPs.
- Rejection text says `loopback or fixture 100.126.128.50`; no broad CGNAT authorization remains.
- Canonical runtime matrix proves endpoint/REST/log behavior and byte-identical `active_plugins`
  restoration after source and ZIP runs.

## Next executable action

None. Preserve branch for coordinator consolidation; do not merge into the product-release lane here.

## Final runtime receipt — canonical fixture

- Temporary `devzone-wordpress-acf-table` disappeared before intervention; no unrelated container was
  stopped. Canonical `devzone-wordpress` reclaimed `8080` and returns HTTP 200.
- Canonical DB port forwarding was restored by a clean `devzone-db` start through installed
  `deck-podman`; WordPress bootstrap returns `http://100.126.128.50:8080/`.
- Source verifier: PASS; ZIP verifier: PASS. Both restored serialized `active_plugins` byte-identically
  and left no `plugins/ipz-verify` symlink or scratch/archive residue.
- Explicit surfaces: `/` 200, `/he/` 200, `/wp-login.php` 200, `/wp-json/` 200 with 315 routes,
  plugin languages REST 401, `/xmlrpc.php` 405, `/wp-admin/` 302.
- Both clean verifier exits include fatal-log scan; no new `PHP Fatal error` or `Allowed memory size`.
- Guard accepts loopback plus only `100.126.128.50`; adjacent, arbitrary CGNAT, and known production
  peers reject with exact fixture-only message.

## Session receipt — 2026-08-11 localhost lane

- Preserved coordinator blocks for production tailnet IPs `100.109.41.8` and `100.116.176.87`; guard
  suite covers both.
- Removed invalid Factory root `package.json`, `pnpm-lock.yaml`, and generated `specs/` harness output.
- Fixed verifier fatal-log marker parsing (`split('\n')`, not literal backslash-n).
- Syntax gates pass: `node --check` for both tools; `bash -n tests/e2e/run-journey.sh`.
- Guard suite passes: 6 tests, 0 failures, 0 skips.
- Preflight probes prove curl error and status `000` both fail with exact fixture repair message before
  scratch, symlink, or WordPress state mutation.
- Operational grep has only blocked-domain/test assertions plus one explicitly labeled Node backend
  static-admin target.
- Initial implementation committed as `200fcdd1c`; coordinator correction is included in the final
  amended commit recorded by session handoff.
- Initial runtime attempt was blocked by temporary `devzone-wordpress-acf-table`; its owner later
  removed it, allowing the final canonical runtime matrix above without terminating unrelated work.
- Tooling prerequisite landed separately: overdeck source commit `b31e0cef7`, main land commit
  `8eed492`; installed `/home/user/.local/bin/deck-podman` contains `KillMode=process` and localhost
  remained HTTP 200 after installed wrapper execution. Full deploy later failed on missing local-only
  buildbox registry in clean clone; exact backup was restored. Existing dirty deploy-clone WIP was
  preserved untouched.

## Owner correction — 2026-08-12

- Source request: delete the obsolete design spec, restore secure dev1 deployment capability, land the latest plugin, and deploy it now.
- Acceptance delta: preserve local verification and current release-integrity hardening; restore only canonical authenticated deployment; verify archive before extraction; verify active remote version and deployed files.
- Preserved WIP: commit `14bcb187a` remains historical evidence; unrelated local-verification improvements remain intact.
- Current receipt: named design spec deleted; canonical deploy script recovered from `14bcb187a^`; restoration `df44706a8` and release `77611679a` landed; dev1 reports active 0.9.42 and all distributed admin assets match the verified ZIP.
- Next executable action: none.
