2026-05-28

The headline is per-tab workspace state: every workspace tab now owns the entire dashboard view — search, filters, sort, favourites, panel layout, the Devices-panel device filter — isolated and persisted per tab, so switching tabs is a full view restore rather than a shared global state. Alongside it, /cdp graduated from a static page to a live interactive explorer (matching /rest, /ws, /mcp), and the bmc CLI gained a one-shot fleet table plus self-healing ingest.

What changed today

Dashboard (builds 238 → 240)

  • Build 238 — audit cycle-333 HIGHs. Cleared the cycle-333 HIGH findings and a live FK-violation storm; fixed audit #070 where the filter useMemo still depended on the singular filterDevice / selectedDomain (the pre-multi-select fields) instead of the plural sets.
  • Build 239 — /cdp becomes an explorer. Rebuilt the CDP page as an interactive explorer in the same shape as /rest, /ws, and /mcp — live, not a static doc.
  • Build 240 — per-tab workspace state (the big one). Each workspace_tab now persists the full view into workspace_tabs.filter:
    • Per-tab search / filters / sort / favourites, isolated and restored on tab switch (was global).
    • Per-tab panel + layout state; fixed a stale cache that clobbered the cold-load.
    • The Devices-panel filter scopes the feed per tab (not just the device list), and all panel counters scope to that per-tab device filter via countDeviceUuids / feedDeviceRestrictSet.
    • Per-tab entry loading is scoped to the device set, fixing empty service-group tabs.
    • Fixed the per-tab device filter being dropped on reload, plus an Amazon-tab feed refresh loop (the device-scoped fetch was dropping the un-indexed host filter). Exhaustive @tabs e2e coverage added.

cdp-connector / bmc (builds 13 → 18)

  • Build 13bmc list / bmc ls: every device + paths + ports + version in one table.
  • Build 14bmc ls defaults to a this-machine view; new bmc attach.
  • Build 15bmc update auto-refreshes the dashboard's record of the device version.
  • Build 16 — fixed dashboard version sync.
  • Build 17 — ingest self-heals on device-removed (closes #30, #34); bmc ls --json (#31).
  • Build 18bmc --client flag stamps an X-Busymate-Client marker on captured traffic.

Supabase (migrations 175 → 177)

  • 176github-webhook posts the issue body as a collapsible blockquote.
  • 177 — render + un-truncate the issue body in the Telegram notification.

Dashboard (build 241) — capture-viewer correctness

A round of device/entry-badge + counter fixes so the feed, the detail panel, and the Devices sidebar agree. All found + verified against a CDP device (Chrome captured via bmc) routing through an external AU proxy.

  • CDP entries no longer read "OFF" in the detail panel. The feed row derives its mode badge from entry.source === "cdp" first, but the entry detail header typed connectionMode as "VPN" | "PAC" | "OFF" and never inspected source — so a CDP entry (no routedVia, device not PAC-routing) fell through to OFF while the row showed CDP. The same connectionMode also drives the device glyph (Chrome icon vs smartphone), so the detail pill diverged in both badge and icon. Fixed by adding the source === "cdp" case + widening the type; the detail now shows the violet CDP pill + Chrome glyph like the feed.
  • External-proxy egress flag now shows on CDP entries. The Devices-panel row resolves its egress flag with a final proxy?.country fallback to the live deviceProxies map (CountryFlow), but the feed-row + detail-pill derivations stopped at deviceProxyCountries (the persisted external_proxy_country column). CDP devices never populate external_proxy_country (the connector only reports real_country_code), so a Chrome routing through an external proxy showed its egress (AU/Sydney, confirmed by cf-ray …-SYD) on the device row but not on its entries. Fixed by threading deviceProxies into EntryList + EntryDetail and adding the same fallback.
  • Egress badge renders as flag + ISO code. The live deviceProxies.country can be a full country name ("Australia"), so the shared entry pill now normalizes it through countryToIso(...) (like CountryFlow) and renders flag + 2-letter code (🇦🇺 AU) — matching the real-country + conn-type chip format, not a bare name with a broken flag class.
  • Devices-panel "All devices" total now agrees with its rows. deviceScopedTotal (the "All devices" count) is tab-scoped, but each per-device row count was the global lifetime total — so on a tab filtered to one device the aggregate collapsed to that device while sibling rows still showed fleet-wide counts, making "All devices" read smaller than a member row. Per-device counts now follow the tab's device scope (scopedCounts): out-of-scope devices read 0, so All = sum of the visible rows and matches the feed.

Dashboard (build 242) + Supabase (178) — TestFlight + Users admin

Fixes + new admin features in Settings → Users (and the /testflight page).

  • Tester delete works on stale rows. A tester revoked/removed on App Store Connect returns 404 ("no resource of type betaTesters"), which the UI treated as a hard error, so a REVOKED row could never be cleared. delete_tester now coerces ASC 404 → success, and both remove surfaces (UsersClient, TestFlightClient) treat 404 as done.
  • Internal beta groups are guarded. Assigning an email tester to an INTERNAL group is rejected by ASC ("Tester(s) cannot be assigned") — internal groups only hold ASC team members. The invite picker + add-to-group chips disable internal groups (with a tooltip) until the email is an accepted team member.
  • Remove user (new). New admin-delete-user Edge Function (service-role, admin-gated, self- + last-admin-guarded). Admin-only Remove button → dialog with keep devices + captures (login/profile/tokens deleted; devices unowned) or purge everything (devices + entries also deleted).
  • Automated internal-tester invites (new). Internal testers must be ASC team members, so the TestFlight tab gains an Internal testing section: Invite as internal tester sends a userInvitations request with the least-privilege Customer Support role, tracks Invite pending (with Cancel), and unlocks the internal beta-group chips once the person accepts Apple's join-team email. New testflight-admin actions: invite_user, list_users, list_user_invitations, cancel_user_invitation. Inherently two-phase — Apple requires the invitee to accept the team invite; no API bypasses that.

Dashboard (build 243) — internal-testing on /testflight

The internal-tester invite automation (242, Settings → Users) now also lives on the standalone /testflight page as an Internal tab: an Invite internal tester form (Customer Support role via userInvitations), a Pending team invites list with Resend + Cancel per row, and a read-only Team members list with roles. Resend has no ASC endpoint, so it cancels + re-creates the invitation (re-sends the email, resets expiry, keeps the original role). The /testflight external-invite form also disables internal beta groups (with a tooltip) to match the Users-tab guard. Dashboard-only — the edge actions shipped in 178.

Dashboard (build 244) — TestFlight redesign + instant Cancel; issue auto-triage Action

  • TestFlight Cancel/invite no longer needs a hard refresh. The invite/resend/cancel handlers refetched ASC's eventually-consistent invitations list, so a just-cancelled invite reappeared until ASC propagated. Now the local state updates optimistically (cancel removes the row, invite appends, resend swaps) — on both /testflight and the Users → TestFlight tab.
  • TestFlight view redesign. All three /testflight tabs got a visual pass: avatar initials with stable per-person tint, each ASC role as its own colored badge, "Awaiting acceptance" + expiry on pending invites, count pills, polished empty states, tidier group cards. Pure markup/shadcn — no behavior change.
  • New: Claude auto-triage GitHub Action (.github/workflows/claude-issue-triage.yml). On issues.opened it runs anthropics/claude-code-action@v1 in automation mode and reacts immediately — reads the issue, posts one triage comment (summary · component · severity · first step) and applies labels. Triage-only (no code edits / PRs), treats issue text as untrusted. Needs a one-time repo setup to activate: the Claude GitHub app + an ANTHROPIC_API_KEY secret.

Dashboard (build 245) + Docs (139) — pending internal-group adds; docs accuracy pass

  • TestFlight: team members not in an internal group are now flagged. The Internal tab reads the testers list to tell which accepted ASC team members aren't in any internal beta group yet, flags them "needs group" (sorted to top + count badge), and offers a per-row Add to <group> button + an Add all. The add reuses add_to_group (existing betaTester) or invite straight into the group (works once they're a team member), clearing the badge optimistically. Closes the invite → accept → add gap.
  • Docs accuracy pass. MCP tool count corrected to 57 (mcp.md, supabase.md, how-to/_meta.json); added the missing Push category to mcp.md (the source of the count drift — doc now matches source exactly); added a TestFlight admin section to dashboard.md. No broken links / orphan pages found.

Supabase (179) + Docs (140) — last-admin safety net (#37) + public docs (#36)

  • #37 [MEDIUM] — admin-delete-user can no longer leave zero admins. Audit cycle 350 found a TOCTOU: the function counted admins then deleted non-atomically, so two concurrent removals of the last two admins could both pass → zero admins (unrecoverable). Fixed at the DB level — a BEFORE DELETE OR UPDATE trigger on public.profiles (prevent_last_admin_removal) blocks any op dropping below one admin (covers the auth.users cascade delete AND demote-to-viewer), with a transaction-scoped advisory lock to serialize concurrent removals. Verified on the live DB; EXECUTE revoked from anon/authenticated.
  • #36 [enhancement] — docs made public + CLI README. A tester couldn't open docs.busymate.net/architecture/cdp-connector — the docs site was auth-gated (web/docs/middleware.ts redirected to /login). The docs site is now public (middleware no-op; sign-in routes left intact + reversible), plus 5 content pages that 500'd once exposed were fixed. cli/README.md rewritten into a full bmc guide (verified against live --help).

Build numbers

  • Dashboard: 237 → 238 → 239 → 240 → 241 → 242 → 243 → 244 → 245.
  • Docs: 138 → 139 → 140.
  • cdp-connector: 12 → 13 → 14 → 15 → 16 → 17 → 18.
  • Supabase: 174 → 175 → 176 → 177 → 178 → 179.