fix(api): make the enrollment-key "Hide expired" filter agree with the status badge (#3191) - #3196
Conversation
…e status badge (#3191) PR #3045 taught the Enrollment Keys row badge that once a parent key is dead the row is judged by its installer tokens — the things that actually enroll — so an Add-Device key whose 60-minute parent aged out while its 30-day installer link keeps working correctly renders "Active". The list route's `expired` filter never got the same treatment. It tested `enrollment_keys.expires_at` alone, so turning on "Hide expired" hid rows the same page was rendering Active: both Active and invisible, and the operator had no way to reach a key that was still genuinely usable. The carve-out reuses the predicate that already spares such a key from `POST /enrollment-keys/purge-expired`. `enrollmentKeyPurgeGuards.ts` now exposes both wrappers over ONE correlated subquery (`hasNoLiveUnexhaustedBootstrapToken` / `hasLiveUnexhaustedBootstrapToken`), so "Hide expired" can no longer hide a key that "Delete expired" deliberately refuses to delete, and a future change to the predicate cannot land on one path and skip the other. The emitted SQL of both existing purge consumers is unchanged. Scoped deliberately: - Gated on `short_code IS NULL` to mirror `reportsInstallerCapacity` — the API suppresses `installerTokens` for a short-link child (#3034), so its badge is parent-only and the filter must be too. That gate itself is untouched. - The `usage_count >= max_usage` ("Exhausted") axis has never been part of `?expired=` and still isn't. Correcting the expiry axis alone satisfies the invariant that no badge-Active key is ever hidden. - Both branches stay exact complements, so no key is unreachable from both toggle positions. Verified with a new real-Postgres suite: mocked-`db` unit suites return whatever rows they are handed regardless of the predicate, so they cannot prove Postgres evaluates the correlated EXISTS per row — which is the whole fix. Both cases were confirmed to fail against the unpatched route. Closes #3191 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Deploying breeze with
|
| Latest commit: |
8c016db
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://67a913b8.breeze-9te.pages.dev |
| Branch Preview URL: | https://fix-3191-expired-filter-live.breeze-9te.pages.dev |
#3191) Code: - `?expired=true` now compares against SQL `NOW()` instead of a JS-bound `new Date()`, so both branches read ONE clock. With API/DB skew the previous pair was not the exact complement the comment claimed: a key expiring inside the skew window could match both branches or neither, which is precisely the "reachable from neither toggle" failure the partition test rules out. Comments (four reviewers independently flagged overstated claims): - "the badge is Active iff ..." -> "only if". The converse is false, and the next paragraph was itself the counterexample (short-link children, aggregate failure, legacy responses all fall back to parent expiry). - #3034 was miscited as the origin of the `reportsInstallerCapacity` suppression. It is an OPEN issue arguing that suppression is WRONG. The origin is #2992 / PR #2993. Added the coupling note that matters: if #3034 is ever fixed, the `short_code IS NULL` gate must move with it or #3191 returns. - #3045 (the PR) -> #3039 (the issue), matching how the badge and route annotate that change everywhere else. - "exact SQL counterpart of liveConsumed < liveMax" now names its two seams: the JS-vs-Postgres clock, and the fact that `consumed_count <= max_usage` has no DB CHECK behind it (upheld by the conditional UPDATE in installer.ts). - The invariant is qualified: it holds whenever the `installerTokens` enrichment succeeds. `fetchInstallerTokenUsage` degrades to an empty Map on failure, dropping badges back to parent expiry while this WHERE clause cannot degrade — so that window can render an "Expired" row under "Hide expired". - The in-branch build was justified by a nonexistent query cost. `db.select()` only shapes a lazy builder; the real reasons are the per-call `new Date()` and the mocked suites' schema stub. Same correction, with the freeze-"now" consequence spelled out, in the guard's own docblock. - Named the direction of the residual list-vs-purge asymmetry (list may call a short-link child dead while purge spares it; never the reverse, which would be data loss). Tests (+3 cases, 2 -> 5): - Assert `installerTokens` from the SAME response the filter produced, so the badge half of the invariant is checked rather than trusted. Without this, relaxing `reportsInstallerCapacity` flips a row's badge to Active while the filter keeps hiding it — #3191 reintroduced with the suite green. - Partner scope. The carve-out is a correlated EXISTS over a second RLS-forced table; a blind subquery there would silently no-op for every MSP-scoped tech. (System scope is documented as unreachable through this harness — it has no membership row, so it 403s on requirePermission before reaching the route.) - Foreign-org token: the subquery correlates only on parent_enrollment_key_id and leans entirely on RLS for tenant safety. - Multi-token keys (dead 10-slot + live 1-slot, and two live-but-exhausted), where the badge's SUM and the filter's per-row EXISTS could diverge — a single-token fixture cannot test that equivalence at all. - Fixture fixes: hand-maintained `toHaveLength(7)` -> derived from the seed map; short code drawn from the real alphabet; list failures now report the response body. Docs: - Marked the superseded equivalence in the 2026-07-03 enrollment-keys-cleanup plan, which still claimed the purge condition equals the list route's `?expired=true`. 4 of the 5 integration cases were re-confirmed red against the pre-fix route. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Review run: Findings: 11 raised → 10 addressed in 826e4a6, 1 deferred (below); 0 outstanding blockers. All four reviewers independently converged on the same top finding, and it was a real one:
Tests: 2 → 5 cases, from the test-analyzer's two rated-8 gaps:
Deferred (not this PR): silent-failure-hunter flagged that "Delete expired" can return Tests: new suite 5 passed, and 4 of the 5 re-confirmed red against the pre-fix route (the 5th is the RLS case, correctly green either way); CI condition: the Status: review-clean, awaiting maintainer merge. |
…ivy on all open PRs (#3212) ## Why **GHSA-5p4m-2wfm-xmqj / CVE-2026-59870** — quadratic CPU consumption in js-yaml's `!!omap` resolution (3.x and 4.x), rated **HIGH**, fixed in **4.3.1** / 3.15.1. The advisory entered Trivy's vulnerability DB at ~02:25 UTC on 2026-08-07. From that moment every PR whose scan resolved after the DB refresh went red on **both** `Trivy Filesystem Scan` and `Trivy Image Scan` (Web image). PRs scanned before the refresh are still green, which is why the failure looked selective rather than global — it is not. This blocks all six remaining v0.104 PRs (#3184, #3185, #3186, #3194, #3195, #3196) and will block every future PR and main until it lands. These are genuine scan-step failures, not the GitHub Actions outage that hit the earlier batch — the jobs fail at `Run blocking Trivy filesystem scan` / `Scan Web image`, with every prior step green. ## What One `pnpm.overrides` entry. js-yaml 4.3.0 arrives transitively through the Astro / expressive-code docs toolchain; nothing in the repo depends on it directly. The override is **upper-bounded to `<5.0.0`**. Without that bound it resolves to 5.2.2, a major-version jump for `@astrojs/markdown-remark`, `@astrojs/starlight` and `@expressive-code/core`. Bounding it keeps the change a 4.3.0 → 4.3.1 patch bump, matching the convention already used for `undici` and `@babel/core`. The tree's other two js-yaml copies need no action: **3.15.1** is already the fixed 3.x release named in the advisory, and **5.2.2** is unaffected. ## Note on the diff The lockfile carries one hunk unrelated to js-yaml: `anymatch@3.1.3`'s picomatch pin moves 4.0.5 → 4.0.4. That is pre-existing drift between `package.json` and the committed lockfile on main which a fresh resolve normalizes — not something this change introduces. Left as the resolver produced it rather than hand-editing the lockfile into an inconsistent state. Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Docs staleness sweep for the v0.102.0..v0.104.0 range (tracked in `scripts/docs-review/last-reviewed.json`). ## Page updates - **features/ai.mdx** — rewrote the Tier 3 approval workflow for the supervised/four-eyes split (#3175): supervised actions are approved by the requesting technician in chat (gated on their own permissions); a fixed high-stakes list (financial, tenant shape, M365/Google identity, restores/rollbacks, computer control, containment release) requires a second approver, now with a 60-minute window and content-change pinning. Sole-operator and `approvals:decide` sections rescoped to four-eyes; disabled/invited users noted as ineligible approvers. - **features/devices.mdx** — WAN IP / LAN IP opt-in device-list columns (#2996). - **features/quotes.mdx** — new "Ordering What You Sold" section: the To-be-ordered procurement breakdown on won quotes, Mark ordered / Mark received tracking, CSV export, vendor cost snapshot, Pax8 badges (#3111). - **features/scripts.mdx** — partner-wide ("All my organizations") scripts, the Available-to picker, and the full-partner-access requirement for partner-wide writes (#3262/#3263); fixed the API section's org-only claim. - **features/edr-integrations.mdx** — chunked resumable package uploads with progress (#3113). - **features/snmp.mdx** — poll due-check runs off attempts; failing devices back off exponentially (scheduler description + troubleshooting entry) (#3223). - **agents/enrollment-keys.mdx** — installer capacity figure and key status derived from live bootstrap tokens (#2993/#3045/#3196). Verified as already self-documented in range (no action): Quick Support (#3153), third-party ring auto-approve (#3150), VSS writer health + partial status (#3005/#3030), EVENT_LOOP_MONITOR_*/DB_POOL_HEALTH_* env vars (#3024/#3224), strict env validation (#2979), custom alert-condition retirement (#2995), enrollment idempotency (#3063), extensions install scoping (#3032). ## Housekeeping - `scripts/docs-review/mapping.json` — added `quickSupport*`, `eventLoop*`, `dbPoolHealth*` patterns; `approvals.ts` now also maps to features/ai.mdx; `partnerWideAccess.ts` now also maps to features/scripts.mdx. - `packages/shared/src/utils/docsMapping.ts` — `/remote/quick-support` → Quick Support section anchor (+ test). `@breeze/shared` tests green (1661 passed). - `apps/api/src/data/docsIndex.json` regenerated (147 docs indexed). - `docs/release-notes/next-release-draft.md` cleared; last release set to v0.104.0 (2026-08-08). - `scripts/docs-review/last-reviewed.json` bumped to v0.104.0. Docs build verified: `astro build` — 150 pages, no errors. **Follow-up (not in this PR):** `docsMapping.ts` maps `/ai-risk` → `/features/user-risk/`, but the AI Risk Engine (approval history, tiers) is documented in `features/ai.mdx` — one of the two should be reconciled. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Todd Hebebrand <todd@lanternops.io> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
The bug
PR #3045 taught the Enrollment Keys row badge (
getKeyStatus) that once a parent key is dead the row is judged by its installer tokens — the things that actually enroll. So an Add-Device key whose 60-minute parent aged out while its 30-day installer link keeps working correctly renders Active.The list route's
expiredfilter never got the same treatment: it testedenrollment_keys.expires_atalone. Turning on Hide expired therefore hid rows the very same page was rendering as Active — the key was both Active and invisible, with no way for the operator to reach a credential that still genuinely works.The fix
The carve-out reuses the predicate that already spares such a key from
POST /enrollment-keys/purge-expired.services/enrollmentKeyPurgeGuards.tsnow exposes both wrappers over one correlated subquery:hasNoLiveUnexhaustedBootstrapToken()(existing,notExists) — unchanged emitted SQL for both purge consumers.hasLiveUnexhaustedBootstrapToken()(new,exists) — used by the list filter.That is the exact SQL counterpart of the badge's
liveConsumed < liveMaxtest. Sharing one definition means "Hide expired" can no longer hide a key that "Delete expired" deliberately refuses to delete, and a future change to the predicate cannot land on one path and skip the others.Invariant pinned: no key the badge renders Active is hidden by
?expired=false.Scope — deliberately narrow
short_code IS NULLto mirrorreportsInstallerCapacity: the API suppressesinstallerTokensfor a short-link child (Installer capacity is suppressed when an installer is built from a short-link child key #3034), so its badge is parent-only and the filter must be too. That gate itself is untouched — Installer capacity is suppressed when an installer is built from a short-link child key #3034 remains its own accepted trade-off. The purge guard applies no such gate and shouldn't: suppressing a confusing capacity number is cosmetic, deleting a key out from under a live token is irreversible.usage_count >= max_usage("Exhausted", amber) has never been part of?expired=and still isn't; an exhausted-but-unexpired key lists exactly as before. Correcting the expiry axis alone is sufficient for the invariant above.db.select.Verification
New real-Postgres suite
apps/api/src/routes/enrollmentKeysExpiredFilter.integration.test.ts, registered in bothvitest.integration.config.ts(include) andvitest.config.ts(exclude). The mocked-dblist suite returns whatever rows it is handed regardless of the predicate, so it cannot prove Postgres evaluates the correlated EXISTS per row — which is the entire fix.Matrix covered: live unexhausted token (the regression), token itself expired, token fully consumed, no tokens, unexpired parent, never-expiring parent, short-link child, and
expired=true/expired=falsecomplementarity includingpagination.total.enrollmentKeysPurgeExpired+enrollmentKeyCleanupintegrationapps/webEnrollmentKeyManagertsc --noEmit -p apps/apieslinton changed filesCloses #3191
🤖 Generated with Claude Code