Skip to content

Clear the fast-uri advisory, and the gates and skips that were not holding - #297

Merged
important-new merged 8 commits into
InspectorHub:mainfrom
important-new:fix/fast-uri-advisory
Aug 5, 2026
Merged

Clear the fast-uri advisory, and the gates and skips that were not holding#297
important-new merged 8 commits into
InspectorHub:mainfrom
important-new:fix/fast-uri-advisory

Conversation

@important-new

Copy link
Copy Markdown
Contributor

Follow-up to #296: one security advisory, and the gates and test suite that #296's review exposed as weaker than they looked.

The advisory

fast-uri — host confusion via a backslash authority introducer, high, runtime scope — surfaced after #296 merged. It arrives transitively through @modelcontextprotocol/sdkajv, so it takes an override. Pinned ^3.1.5 rather than >=3.1.5: the open range resolved to 4.1.2, and forcing a major on a transitive that ajv@8.20 expects at 3.x is a way to break schema validation while fixing a URL parser.

Gates that were not ratcheting

Dead-code baseline is now empty. Its two entries were the same declaration twice — ERASURE_OUT_OF_SCOPE and its type, which scripts/check-erasure-manifest.mjs reads out of the source text rather than importing, so knip was right that no module consumes it and wrong that it is dead. Marked per-export with a @gateConsumed tag rather than exempting the whole file, so a genuinely dead export in that manifest still gets reported.

Status literals: 13 → 5. All eight server-side entries now reference the enum their column already declares. They turned out to be four different state machines, kept separate rather than collapsed — a visit is not the order it belongs to, and an outbox row's published ("handed to the queue") is not a report's published ("delivered to the client"). The remaining five are app/ render branches.

That gate also has a blind spot worth knowing about: its union-type guard /^\s*\|/ matches the | of a || too, so in status === 'x' || status === 'y' every comparison but the last is silently dropped. Probing for it found one genuinely unseen literal, now fixed. The guard itself is left alone here — tightening it surfaces new hits, which is a separate change.

verify did not depend on e2e. It is the job branch protection keys off, so a red Playwright run left it green: the suite ran, reported, and could not block a merge. Nobody decided E2E should be advisory. It costs verify the wait for the longest job, which is the price of the name being true.

The skipped tests were hiding something worse than debt

41 skipped → 21. The nine that were unskipped had all been waiting on a "multi-user seed harness" that already existed. Once they ran, every one asserted a contract that does not ship:

  • a [title="Add building"] control and a window.prompt from the Alpine build; the surface is a drawer
  • a republish summary field in the editor's publish modal, which has never had one
  • /inspections/:id/versions/:n/diff as a page, when it is and always was an API path
  • a five-gate pre-flight checklist modal that never shipped — the aggregator exists as GET /:id/preflight with no frontend consumer

They would have been red the day they were written. The skip was presenting "we did not build this" as "this test cannot run yet." Also fixed: those specs never logged in at all, so they were driving anonymous pages at routes that bounce to /login.

Eleven more tests were deleted rather than repaired — they target Alpine-era files the React Router migration removed, and four had empty bodies, so they were placeholders inflating a count. Where that emptied a spec file, its playwright.config.ts project entry went too: a project whose testMatch resolves to nothing is another way to report green over zero tests.

A requested seed now fails loudly. SEED_E2E=1 seeding was wrapped in a console.warn catch, which is exactly what hid five separate defects in the seed fixtures until this week. When seeding is explicitly asked for, its failure aborts the run.

The nine unskipped specs run under a second config (npm run test:e2e:seeded) because the seed writes into the standalone tenant and the default run asserts a cold POST /api/auth/setup returns 200 — mutually exclusive in one D1. The alternative, skipping them unless an env var is set, would have put them straight back in the column this change exists to empty.

Counts: 180 → 189 passing, 41 → 21 skipped, 11 dead tests removed.

🤖 Generated with Claude Code

important-new and others added 8 commits August 5, 2026 16:36
A new advisory surfaced after InspectorHub#296 merged — host confusion via a
backslash authority introducer, high, runtime scope. fast-uri arrives
transitively through @modelcontextprotocol/sdk -> ajv, so it needs an
override rather than a direct bump.

Pinned `^3.1.5`, not `>=3.1.5`: the open range resolved to 4.1.2, and
forcing a major on a transitive that ajv 8.20 expects at 3.x is a way to
break schema validation while fixing a URL parser. The MCP suite (85
tests) passes on 3.1.5.

Lockfile updated in place; linux entries still 346.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
The knip baseline held exactly two entries, and they were the same thing
twice: `ERASURE_OUT_OF_SCOPE` and its `ErasureOutOfScopeEntry` type. Neither
is dead. `scripts/check-erasure-manifest.mjs` is a plain .mjs gate against a
TypeScript manifest, so it reads the declaration out of the SOURCE TEXT
(`arrayBody(src, "ERASURE_OUT_OF_SCOPE")`) instead of importing it — a
consumption no module-graph analyzer can see.

A baseline entry says "this is dead and we tolerate it". Only "a tool
consumes this" was true, so it is now said that way: the two exports carry a
`@gateConsumed` JSDoc tag, wired through knip `tags: ["-gateConsumed"]`, with
the reason written at the declaration.

Chose the tag over adding the file to `entry`: `entry` would have exempted
the whole file, so a future dead export in the manifest would go unreported.
Verified by canary — a throwaway unused export added to that same file still
fails the gate (exit 1), and removing it returns exit 0.

scripts/knip-baseline.json is now `[]`, and the gate docstring says it must
stay that way, with the three legitimate ways to declare a new finding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
All 8 server-side entries in the status-literal baseline are gone; the
baseline drops 13 -> 5 and the 5 that remain are the app/ render branches,
left alone deliberately.

They were three different state machines, not one:

- REPORT axis (server/api/inspections/publish.ts) — the three transition
  responses now return REPORT_STATUS.SUBMITTED / .IN_PROGRESS.

- INSPECTION axis (server/services/concierge.service.ts) — the confirm-by-
  client write now sets INSPECTION_STATUS.CONFIRMED. The file already
  imported the constant and used it four lines earlier.

- EVENT axis (server/services/event.service.ts, server/lib/google-calendar.ts)
  — a visit, not the order: a radon drop-off is `completed` while its
  inspection is still `confirmed`, and `results_received` has no counterpart
  on the order at all. These derive from EVENT_STATUS, NOT from
  INSPECTION_STATUS, even where the word is identical. event.service.ts also
  carried a hand-written `export type EventStatus` union duplicating the
  canonical one in lib/status/event-status.ts — deleted, nothing imported it.

server/portal/outbox.service.ts was the fourth axis and the interesting one:
its `published` means "handed to the queue", while a report's `published`
means "delivered to the client". Collapsing them into REPORT_STATUS would
have been worse than the literal. The column already declared its enum
inline, so that enum is now named — lib/status/sync-outbox-status.ts, the
same shape as the three existing axes — the schema derives from it
(type-layer only; db:check confirms zero DDL drift) and the service's
pending/published/failed literals all reference it.

Also fixed one bare literal the gate CANNOT see, found while working:
server/services/automation/conditions.ts compared inspection.status against
'cancelled' and 'completed' in a `||` chain. The gate's union-type guard
(`/^\s*\|/`, meant for `status: 'a' | 'b'` type declarations) matches the `|`
of a `||` too, so every comparison in an or-chain except the last is silently
dropped. Reported separately — not fixed here, since tightening the guard
surfaces new hits and this commit is about shrinking the baseline.

test:unit 669 files / 4581 tests passed (1 file, 1 test skipped).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
…ails

globalSetup wrapped seedFixtures() in a try/catch that only console.warn'd.
SEED_E2E=1 means the caller ASKED for the seed and every spec downstream
depends on the rows it writes, so swallowing the failure did not make the run
robust — it made it lie: the specs then failed at a login or a missing
inspection id, which reads as a broken feature rather than a broken fixture.
Five separate defects in tests/seed-fixtures.ts survived months behind that
warning (wrong database name, missing -c, embedded newlines cmd.exe rejects, a
password-hash format verifyPassword can never match, and a tenant id standalone
login cannot resolve).

The seed now throws, and a seedRequested flag carries the throw past the outer
catch — which exists to tolerate a missing local D1 and would otherwise
re-swallow it as the same soft warning. The un-requested (SEED_E2E unset) path
still warns exactly as before.

Verified both directions by running globalSetup directly with a temporary throw
planted in seedFixtures: SEED_E2E=1 threw, SEED_E2E unset returned normally.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
… them

Six skipped tests targeted Alpine-era source the React Router migration
deleted, so they could never be unskipped — they only inflated the suite's
skip count with tests that can never run.

- standalone-browser: UI-11 / UI-NOTIFY / UI-WIDGET. Empty bodies
  (async () => {}) keyed to #agreementsList, #notifyUnreadBadge and
  [data-widget-embed]. Placeholders, not tests — nothing is lost.
- standalone-mobile: M-05, same empty-body shape (Alpine message FAB).
- booking-date-input.spec.ts: the whole file was one describe.skip driving
  the Alpine booking form. File deleted.
- sprint2-regression.spec.ts: both describe.skip blocks read
  src/templates/pages/rating-systems.tsx, src/templates/layouts/
  main-layout.tsx and public/js/auth.js off disk — none of which exist.
  File deleted.

Deleting the last two files empties their projects, so the sprint2-regression
and booking-date-input entries go from playwright.config.ts as well: a project
whose testMatch resolves to nothing is a new way to report green over zero
tests.

Verified the survivors: browser + mobile projects, 33 passed / 0 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
…what is missing

The spec said it was "skipped pending the dual-server harness" and pointed at
tests/global-setup.ts as the gap. That gap has been closed since the multi-user
seed landed: SEED_E2E=1 + tests/seed-fixtures.ts exists and works, and
subsystem-D/E now run on it. Anyone reading the old note would go looking for
something that is already there.

The real blocker is a run environment this repo cannot supply: the portal worker
on 8787 AND this worker on 8789 AND `stripe listen` forwarding real signed
events, all at once. Playwright's webServer starts one server and globalSetup
seeds one D1. Stays skipped, now with a TODO that names what would unblock it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
…ship today

Nine tests that had been skipped since the Alpine build now run and pass, on a
config that gives them a real home instead of an env var nobody sets.

SEED (tests/seed-fixtures.ts)
- inspector-half@seed.test, exported through SEED_EMAILS. The name says
  "half" of the INSPECTION, not of a seat quota: the only spec that logs in as
  it opens seed-half-done-inspection and asserts the publish pre-flight gates
  fail, and nothing anywhere touches seats. It now owns that inspection.
- seed-empty-inspection and seed-republished-inspection are seeded
  propertyType 'commercial'. This is load-bearing, not cosmetic: the editor
  renders the units surface only when propertyType === 'commercial'
  (showUnitsSurface), so without it the unit flows have nothing to drive.
- one inspector_credentials row, so a published report has a badge to render.
- SEED_INSPECTIONS / SEED_TENANT_SLUG exported so specs stop hardcoding ids.

WHAT CHANGED IN THE ASSERTIONS (all: the old contract no longer exists)
- D P1+P2: no Alpine [title="Add building"] + window.prompt. Units are the
  UnitsManager drawer; scope selection is the BreadcrumbDropdown, and only in
  per-unit mode. "selectedUnitId mirrors the click" was a trailing comment and
  is now an assertion.
- D P7+P9: the republish summary prompt is NOT in the editor's PublishModal —
  that modal has no summary field and never had one. It is the hub's
  PublishReportModal (/inspections/:id), shown when the next publish would be
  an amendment. "Send All" exists nowhere in this repo; the submit is
  "Publish report".
- D P8: the page is /version-diff/:id?n=&from= — /inspections/:id/versions/:n/diff
  is the API path and was never a page. Headings are "Version N Changes" +
  Field/Before/After, not "v1 -> v2" / "Items changed".
- E P1: there is no publish modal with a disabled "Send All" and a gate
  checklist; [data-test=publish-send-all] appears nowhere. The five-gate
  aggregator shipped as GET /:id/preflight with NO frontend consumer, so the
  test asserts it there.
- E P2: neither tab id existed. Live keys are all|active|requested|to_review|
  awaiting_payment|published|cancelled — no "drafts", and snake_case. The
  active tab is the DS token class text-ih-primary, not .bg-indigo-600.
- E P6: /settings/integrations (no -grid), owner-only (inspector gets
  AccessDenied), and the six cards are QBO/GCal/Places/Resend/Zapier/Gemini —
  Stripe Connect is its own panel now, not a card.
- E P7: charts are div bars, not svg polyline, and the findings surface is
  "Findings by Section". The date range has to be stated or the card honestly
  renders "No data in this date range" and the test asserts a heading over
  nothing.
- E P8: /report-view/:tenant/:id, and it is NOT anonymously readable (the
  public report API 404s without a recipient token or owner session). "Inspected
  by" is now "Inspector: {name}"; there is no hardcoded NACHI badge — the
  credential comes from the inspector's own row.
Also: P2-P8 of subsystem E had no login at all and were driving ANONYMOUS
pages, because Playwright gives each test a fresh context. Each logs in now, at
the role its surface requires.

RUN HOME (playwright.seeded.config.ts + npm run test:e2e:seeded + a CI step)
The seed writes users into TENANT_A, which IS the standalone workspace, and the
default run's api project asserts POST /api/auth/setup returns a fresh 200 —
which 409s the moment any user exists. The two are mutually exclusive in one
D1, so they are two runs sharing one worker, not two projects. The alternative,
skipping these nine unless SEED_E2E is set, was rejected: it puts them straight
back in the permanently-skipped column this work exists to empty.

Verified: 9 passed. Then falsified — reverting the commercial propertyType and
the credential row reds exactly P1+P2 and E-P8 and nothing else; dropping the
inspector-half row aborts the whole run through Task 1's new fatal seed path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
`verify` is the job branch protection keys off, and e2e was missing from
its needs list. A red E2E run therefore left verify green — the suite ran,
reported, and could not block a merge. Nobody decided E2E should be
advisory; it just was.

Costs verify the wait for e2e, which is the longest job at roughly five
minutes. That is the price of the job meaning what its name says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0125yvQL7kgEym1oR6hiqUAQ
@important-new
important-new merged commit 140df97 into InspectorHub:main Aug 5, 2026
14 checks passed
@important-new
important-new deleted the fix/fast-uri-advisory branch August 6, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant