Verdict: the dashboard was well-built structurally (clean webapp isolation, no XSS sinks, no leaked secrets, a real admin_users authorization check) but not production-ready — one irreversible data-loss bug and a systemic data-reliability flaw where every system-wide stat was computed through the browser's RLS-scoped anon-key client. This branch fixes both, across three layers (frontend, hocuspocus backend, Supabase SQL).
Work was done in an isolated worktree (worktree-admin-dashboard-audit) at your request. Nothing is committed; review the diff and run the developer steps below before deploying.
The dashboard had two data paths. The services/api.ts path (documents, views, retention, ghost accounts) already went through the hocuspocus REST API using service_role behind a real admin-checking middleware — correct. The services/supabase.ts direct anon-key path ran as role authenticated under RLS, so:
users.emailis column-revoked fromauthenticated→fetchUsersthrew42501on every render (P0, Users page down).push_subscriptions/email_queueare table-revoked → push/email stats silently rendered0.notifications/messages/channelscounts were scoped to the admin's own rows → wrong headline numbers.channelslist usedworkspaces!innerunder member-only RLS → near-empty for a non-member admin.- Client-side aggregations read unbounded selects capped at 1000 rows → wrong past that threshold.
The fix routes these through the existing admin-gated, RLS-bypassing service_role API.
- Ghost Accounts data-loss (P1, irreversible): "Select All" + Delete and CSV Export operated on the unfiltered ghost list while the table showed only non-anonymous rows — an admin could permanently delete anonymous accounts never shown. Scoped selection/delete/export to one memoized
visibleGhosts. - Stats re-routing:
fetchSupabaseStats,fetchNotificationStats,fetchPushStats,fetchEmailStats,fetchPushPipelineStats,fetchPushSubscriptionAnalytics,fetchPlatformTrend,fetchTableSizes,fetchUserNotificationSubscriptions,fetchUsers,fetchChannelsnow call the service_role backend. Analytics/trend keep their (correct) client-side aggregation, now fed fleet-wide data; errors surface to React Query instead of rendering silent zeros. - Chart/label correctness:
ViewsTrendChartbound a non-existentunique_sessionsfield → switched tounique_visitors(what the RPC returns); DAU trend relabelled "Users by Last-Seen Day" (it buckets a single last-seen column, not daily activity); summary card relabelled "Engaged Readers" (sums per-document distinct users). - Stale-docs "Export Selected" now exports the selection, not the whole page.
- Auth listener moved to its own mount-once effect so it survives client-side navigation (was torn down after the first route change);
navigateTodep fixed. - Perf: removed the dead 3-table realtime subscription that resubscribed every render (those tables aren't in the publication); debounced the Users-page realtime refetch; made the System health probe a head-only request (no full count).
- Security: CSV formula-injection neutralization (
= + - @prefixes); search sanitization (LIKE wildcards + PostgREST.or()delimiters) now enforced server-side. - Cleanup: removed dead utils/types (
formatNumber,formatPercent,formatDateForExport,logWarn,logInfo,PushDebugStats,RecentPushAttempt, duplicatePushGatewayHealth, deadsanitize.ts); deduped the push-detail mapper; routed services through the dev-safe logger; deleted ~50 banned section-banner comment lines; trimmed the oversized Avatar JSDoc; corrected the README (Pages Router, real structure,dev:ci, env vars) and the misleadingnext.configPPR comment.
- New
admin-stats.controller.ts+ routes under/api/admin/*(all behind the existingadminAuthMiddleware):stats/platform,stats/notifications,stats/email,stats/push,stats/push/pipeline,push/subscriptions,system/table-sizes,users,users/notification-subs,channels. All aggregate via theservice_roleclient (RLS-bypassing, system-wide), paginating past the 1000-row cap where it reads rows.
22-user-retention.sql: addeddeleted_at is nulltoget_retention_metrics,get_user_lifecycle_segments,get_notification_reach; addedis_active = trueto notification reach's push count; changedget_top_active_documents.workspace_iduuid → text(matchesworkspaces.id).11-indexes.sql: added plainidx_messages_created_at(converts 5 analytics paths from seq-scans of the largest table to range scans).09-document-views.sql: privilege-escalation fix — revoked the fourget_document_views_*analytics RPCs fromauthenticated, grantedservice_roleonly (no frontend calls them directly);views_todaynow derived at read time fromdocument_views_daily(was a stale stored snapshot).- Paired migration:
migrations/20260609092031_admin_stats_correctness.sql.
- Apply + verify SQL locally:
bun run --filter @docs.plus/supabase_back resetthenbun run --filter @docs.plus/supabase_back types(regeneratesapps/webapp/src/types/supabase.ts— required:get_top_active_documentschanged its return shape). Commit the regenerated types. - Deploy the hocuspocus backend (new admin endpoints) and the admin-dashboard together — the frontend now depends on those endpoints.
- Smoke-test each admin page against real data (no live DB was available here): dashboard counts, Users (loads + search + email column), Channels (not empty), push/email/notification panels (non-zero), Ghost "Select All" selects only visible rows.
- Confirm prod grants match the scripts (
information_schema.role_table_grants/role_routine_grants) — the scripts are canonical, but whether prod had already drifted from them could not be observed from here.
- Dead deps (
@supabase/ssr,dotenv,eslint-config-next;@types/node→catalog:): left for a controlledbun installso the sharedbun.lockdiff stays scoped. - True DAU rebuild from a per-day activity source, global-distinct unique visitors recompute: new data pipelines, not relabels — out of scope.
- Consolidating the per-enum head-counts into SQL aggregate RPCs (review item B1): fewer round-trips and a smaller controller — but it adds untested
SECURITY DEFINERSQL (nodb resethere) with subtle traps (rounding, thedeleted_atasymmetry between channels and notifications). Adding unverifiable SQL risks the reliability the audit just secured, so it's deferred; the per-enum head-counts are plain, verified TS. rechartscode-splitting,SortDirection/DLQViewer/className-merge consolidation: low-payoff, internal tool.- No tests added (repo policy: opt-in). The highest-value one later would be a Cypress check that Ghost "Select All" selects only visible rows.
A strict structural review of this diff returned clean-with-quick-wins and confirmed the audit's own fixes are correct. Applied:
- Deleted dead code the rewrite carried forward:
fetchPlatformTrend+PlatformTrendPointhad zero callers. - Fixed a latent bug it introduced:
getPlatformStats/getNotificationStatsAdmindidn't check.erroron their count queries, so a failed count returned HTTP 200 with a silently-zeroed stat. They now throw (→ 500), matching the email/push handlers. - Documented the
push_subscriptionsfull-table-transfer scaling cliff atgetPushSubscriptionsRaw.
Deliberate behavior change to ratify: "Export Selected" on the stale-documents page now exports the selection (it previously exported the whole page — the button was mislabeled).
Any new admin-only data path must go through an is_admin()-gated SECURITY DEFINER RPC or the service_role hocuspocus API — never a direct anon-key table read. The browser client is RLS-scoped and several admin tables/columns are revoked from authenticated, so direct reads return wrong, zero, or error results.