Skip to content

fix(api): make the enrollment-key "Hide expired" filter agree with the status badge (#3191) - #3196

Merged
ToddHebebrand merged 3 commits into
mainfrom
fix/3191-expired-filter-live-tokens
Aug 7, 2026
Merged

fix(api): make the enrollment-key "Hide expired" filter agree with the status badge (#3191)#3196
ToddHebebrand merged 3 commits into
mainfrom
fix/3191-expired-filter-live-tokens

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

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 expired filter never got the same treatment: it tested enrollment_keys.expires_at alone. 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.ts now 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 < liveMax test. 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

  • Gated on short_code IS NULL to mirror reportsInstallerCapacity: the API suppresses installerTokens for 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 untouchedInstaller 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.
  • The exhaustion axis is not touched. 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.
  • Both branches stay exact complements, so no key becomes unreachable from both toggle positions.
  • Both guards are built inside their branch — an unfiltered list request (the toggle is off by default) issues no extra db.select.

Verification

New real-Postgres suite apps/api/src/routes/enrollmentKeysExpiredFilter.integration.test.ts, registered in both vitest.integration.config.ts (include) and vitest.config.ts (exclude). The mocked-db list 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=false complementarity including pagination.total.

Check Result
New integration suite 2 passed — and confirmed red against the unpatched route (not vacuous)
enrollmentKeysPurgeExpired + enrollmentKeyCleanup integration 16 passed total across the three guard-sharing suites
Affected API unit suites (4 files, serial) 128 passed
apps/web EnrollmentKeyManager 19 passed (no web change; badge untouched)
tsc --noEmit -p apps/api clean
eslint on changed files clean

Closes #3191

🤖 Generated with Claude Code

…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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

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

View logs

#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>
@ToddHebebrand

Copy link
Copy Markdown
Collaborator Author

Review run: /pr-review-toolkit:review-pr — code-reviewer, pr-test-analyzer, comment-analyzer, silent-failure-hunter (4 agents, parallel).

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:

  • Clock split (all 4 agents). expired=true bound a JS new Date() while expired=false used SQL NOW(). Under API↔DB skew a key expiring in that window matched both branches or neither — exactly the "reachable from neither toggle" failure the new partition test claims to rule out, and the comment asserting "exact complements" was therefore false. Both branches now read one NOW().
  • #3034 was miscited as the origin of the reportsInstallerCapacity suppression. It is an open issue arguing that suppression is wrong; the origin is Download Installer shows enrollment-key usage 0/1 instead of 0/X until installer is first run #2992 / PR fix(api,web): show real installer capacity on Enrollment Keys (#2992) #2993. Corrected, plus the coupling note that actually matters: if Installer capacity is suppressed when an installer is built from a short-link child key #3034 is ever fixed, the short_code IS NULL gate must move with it or [UI][API] Enrollment Keys "Hide expired" filter is parent-only — hides rows the badge correctly shows as Active #3191 comes straight back for those rows.
  • #3045#3039 — the badge and route annotate that change by issue everywhere else, so grepping #3045 in EnrollmentKeyManager.tsx returned nothing.
  • "iff" was false (the next paragraph was its own counterexample) → "only if". "exact SQL counterpart" now names its two seams: the JS-vs-Postgres clock, and that consumed_count <= max_usage has no DB CHECK — it is upheld only by the conditional UPDATE in routes/installer.ts.
  • The invariant is now qualified. fetchInstallerTokenUsage degrades to an empty Map on failure, dropping every badge back to parent expiry while this WHERE clause cannot degrade — so in that window the page can render an "Expired" row under "Hide expired". Documented rather than papered over; it is loud in Sentry, and degrading the row set would be strictly worse.
  • A comment justified the in-branch build with a query cost that doesn't exist (db.select() is a lazy builder). Replaced with the real reasons — the per-call new Date() (hoisting would freeze "now" for the process lifetime, a genuine correctness bug) and the mocked suites' schema stub.

Tests: 2 → 5 cases, from the test-analyzer's two rated-8 gaps:

  • installerTokens is now asserted from the same response the filter produced, so the badge half of the invariant is checked instead of trusted. Without it, relaxing reportsInstallerCapacity flips a badge to Active while the filter keeps hiding the row — [UI][API] Enrollment Keys "Hide expired" filter is parent-only — hides rows the badge correctly shows as Active #3191 reintroduced with the suite fully green.
  • Partner scope (correlated EXISTS over a second RLS-forced table; a blind subquery would silently no-op for every MSP-scoped tech). System scope is documented as unreachable through this harness — no membership row, so it 403s on requirePermission before reaching the route.
  • Foreign-org token, since the subquery correlates only on parent_enrollment_key_id and leans entirely on RLS for tenant safety.
  • Multi-token keys — the badge SUMs and the filter does a per-row EXISTS; a single-token fixture cannot exercise that equivalence at all.

Deferred (not this PR): silent-failure-hunter flagged that "Delete expired" can return { success: true, deletedCount: 0 } and silently no-op on rows the list presents as expired, with a toast that reads like success. Pre-existing, on the delete path, and needs a wire field (skippedLiveInstallerCount) plus UI — worth its own issue rather than widening this one.

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); enrollmentKeysPurgeExpired + enrollmentKeyCleanup integration 19 passed total across the three guard-sharing suites; 4 affected API unit files 128 passed; apps/web EnrollmentKeyManager 19 passed; tsc --noEmit -p apps/api clean; eslint clean.

CI condition: the pull_request trigger dropped for this branch — gh run list showed zero Actions runs and gh pr checks showed only a Cloudflare Pages entry, which reads deceptively like a green board. Dispatched run 31127345406 by hand via workflow_dispatch; it was queued at hand-off. Confirm that run is green before merging — the check list on the PR is not evidence on its own here.

Status: review-clean, awaiting maintainer merge.

@ToddHebebrand ToddHebebrand reopened this Aug 7, 2026
ToddHebebrand added a commit that referenced this pull request Aug 7, 2026
…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>
@ToddHebebrand
ToddHebebrand merged commit f09cd1c into main Aug 7, 2026
56 checks passed
@ToddHebebrand
ToddHebebrand deleted the fix/3191-expired-filter-live-tokens branch August 7, 2026 16:22
ToddHebebrand added a commit that referenced this pull request Aug 8, 2026
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>
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.

[UI][API] Enrollment Keys "Hide expired" filter is parent-only — hides rows the badge correctly shows as Active

1 participant