Skip to content

P0: stop baseball onboarding demoting admins + honest Bridge panel fallback - #736

Merged
njrini99-code merged 1 commit into
mainfrom
fix/p0-admin-access-model
Jul 3, 2026
Merged

P0: stop baseball onboarding demoting admins + honest Bridge panel fallback#736
njrini99-code merged 1 commit into
mainfrom
fix/p0-admin-access-model

Conversation

@njrini99-code

Copy link
Copy Markdown
Owner

Root cause (H1 confirmed, H2 ruled out)

Every get_admin_*_rollup SECURITY-DEFINER function gates on users.role='admin'. The Bridge app layer gates on the SUPER_ADMIN_USER_IDS env allowlist. Those two agreed until baseball onboarding's users upsert ({ onConflict: 'id' }, service-role client, no ignoreDuplicates) clobbered the allowlisted super-admin's row from admincoach. After that, requireSuperAdmin() passed and every panel's RPC died with 42501. H2 (session not reaching the RPC) ruled out: fetchAdminRollupA uses the request-scoped server client, and the RPC succeeds with only a sub claim in a simulated call.

Already done live (no deploy needed)

  • users.role restored to admin for admin@helmsportslabs.com (04:18Z).
  • Verified: simulated authenticated calls to get_admin_dashboard_rollup / get_admin_rounds_rollup return data; exactly one role='admin' row and it IS the allowlist entry — the two admin models agree again.

This PR (prevents recurrence + honest UX)

  • ensureUserRowPreservingAdmin() at both baseball onboarding users-row writes: insert-if-missing, player↔coach conversion allowed, an existing admin is NEVER overwritten. (Golf onboarding already used ignoreDuplicates and could not demote.)
  • PanelBoundary/PanelStale: the fallback claimed "showing last known data" while rendering none. Copy now says "temporarily unavailable" and ships a real retry (router.refresh() + boundary remount).
  • panel-boundary.test.tsx: asserts the honest copy, the retry path, and that the misleading claim never returns (7/7 green).

Chosen model per sweep option 2: users.role='admin' stays the DB source of truth, env allowlist stays the app-layer second factor, and onboarding can no longer un-sync them — only one super-admin exists, so an is_super_admin() table indirection adds surface without adding safety today.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fz1ajgmqcEp1yPEzjnXgY5

…allback with retry [vercel skip]

P0 from the 2026-07-03 Mission Control sweep. Root cause (H1 confirmed):
every get_admin_*_rollup SECURITY-DEFINER function gates on
users.role='admin', but baseball onboarding's users upsert
({ onConflict: 'id' }, service-role client, no ignoreDuplicates) clobbered
the allowlisted super-admin's row down to role='coach' — so
requireSuperAdmin() passed and every Bridge panel then died with 42501.
The role was restored live (2026-07-03 04:18Z, verified: simulated
authenticated calls to get_admin_dashboard_rollup/get_admin_rounds_rollup
return data); this commit makes the demotion impossible to reintroduce:

- ensureUserRowPreservingAdmin(): both baseball onboarding users-row writes
  now insert-if-missing / allow player<->coach conversion / NEVER overwrite
  an existing 'admin'. (Golf onboarding already used ignoreDuplicates and
  could not demote.)
- PanelBoundary/PanelStale: the error fallback claimed "showing last known
  data" while rendering none. Copy now tells the truth ("temporarily
  unavailable") and ships a real retry (router.refresh() + boundary remount).
- panel-boundary tests: assert the honest copy, the retry path, and that the
  misleading claim never comes back.

H2 (session not reaching the RPC) ruled out: fetchAdminRollupA uses the
request-scoped server client and the RPC succeeds with only a sub claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fz1ajgmqcEp1yPEzjnXgY5
@vercel

vercel Bot commented Jul 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
helmv3 Ready Ready Preview, Comment Jul 3, 2026 4:52am

Request Review

@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Summary by CodeRabbit

  • New Features

    • Added a per-panel Try again action for admin panels that reloads the content and recovers the panel after an error.
  • Bug Fixes

    • Improved error states so failed panels no longer imply they are showing outdated data.
    • Refined panel messaging for a clearer, shorter unavailable-state label.
    • Preserved existing admin access during onboarding and signup flows, preventing role changes from being overwritten.

Walkthrough

This PR adds a retry mechanism to admin panel error boundaries (PanelRetryButton triggering router.refresh and boundary remount via attempt-key state), updates PanelStale's copy and action prop, adds corresponding tests, and introduces an ensureUserRowPreservingAdmin helper in onboarding to prevent role upserts from demoting existing admin users.

Changes

Panel Retry Flow

Layer / File(s) Summary
Retry button and boundary reset wiring
src/app/admin/_components/PanelBoundary.tsx
Adds PanelRetryButton (calls router.refresh() in a transition and an onRetryReset callback), widens PanelErrorBoundary props with onRetryReset, updates reset() to clear error and invoke the callback, and adds attempt state with key={attempt} in PanelBoundary to force remount on retry.
PanelStale action prop and copy
src/app/admin/_components/PanelStates.tsx
PanelStale gains an optional action prop rendered in the UI; status copy changes from "last known data" to "temporarily unavailable".
Retry and stale-state test coverage
src/app/admin/_components/__tests__/panel-boundary.test.tsx
Mocks next/navigation's useRouter().refresh, adds tests confirming crash fallback omits "last known data", confirms "Try again" triggers refresh and recovers content, and updates stale-state assertions to match new copy.

Estimated code review effort: 2 (Simple) | ~12 minutes

Admin-Preserving Onboarding Role Updates

Layer / File(s) Summary
ensureUserRowPreservingAdmin helper and call sites
src/app/baseball/actions/onboarding.ts
Adds a helper that reads the existing users.role, skips changes if already the target role or admin, and otherwise inserts/updates the role; replaces unconditional upserts in runCompleteCoachOnboardingCore and completeBaseballSignup so admin roles are never overwritten.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant PanelRetryButton
  participant Router as next/navigation Router
  participant PanelErrorBoundary
  participant PanelBoundary

  User->>PanelRetryButton: click "Try again"
  PanelRetryButton->>Router: refresh()
  PanelRetryButton->>PanelErrorBoundary: reset()
  PanelErrorBoundary->>PanelErrorBoundary: clear stored error
  PanelErrorBoundary->>PanelBoundary: onRetryReset()
  PanelBoundary->>PanelBoundary: attempt++ (remount via key)
  PanelBoundary->>PanelErrorBoundary: remount with refreshed children
Loading

Suggested labels: security


Caution

Pre-merge checks failed

Please resolve all errors before merging. Addressing warnings is optional.

  • Ignore

❌ Failed checks (1 error, 1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Auth Check In Server Actions ❌ Error src/app/baseball/actions/onboarding.ts:301-391 exports signupAndCompleteCoachOnboarding and hits .from('users') at 387 without supabase.auth.getUser(). Add a pre-DB getUser() gate before the duplicate-email branch, or split the auth-free signup path into a non-action helper if this flow should stay unauthenticated.
Title check ⚠️ Warning The title is related, but it is not Conventional Commits and omits a required scope for the onboarding and panel changes. Rewrite it as a Conventional Commit with one allowed scope, e.g. fix(baseball): preserve admin users and improve panel retry fallback.
Conventional Commits ❓ Inconclusive Squashed subject matches the Conventional Commits regex, but the PR title isn’t available in the checkout to verify. Provide the actual PR title or GitHub PR metadata; this repo snapshot only exposes the squash commit subject.
✅ Passed checks (9 passed)
Check name Status Explanation
Description check ✅ Passed The description directly matches the baseball onboarding safety fix and the Bridge panel retry/fallback UX work.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
No Service-Role In Client Bundles ✅ Passed Changed files only use createAdminClient() in src/app/baseball/actions/onboarding.ts:4,471; the service-role key is confined to exempt src/lib/supabase/admin.ts:4-20, and no changed file refe...
Rls Coverage On New Tables ✅ Passed No migration files were changed in this PR diff; only TS/TSX app files were modified.
Sport-Prefixed Table Names ✅ Passed PASS: onboarding.ts uses baseball_* tables only at lines 158-233, 475-551, and 666; UI/test files contain no Supabase queries.
No Destructive Writes ✅ Passed PASS: touched write paths in onboarding.ts use select/upsert/update/insert only; no DELETE-then-INSERT sequence appears in changed files.
No Edits To Historical Migrations ✅ Passed Diff vs origin/main touches only src/app files; no files under supabase/migrations/ are modified, so no historical migration edits occurred.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/p0-admin-access-model
  • 🛠️ helm safety pass
  • 🛠️ dashboard ux pass
  • 🛠️ rls test pass

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ast-grep (0.44.0)

ast-grep could not parse rule config: /ast-grep-rules/../git/.coderabbit/ast-grep/no-explicit-any.yml


Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Prevent onboarding from demoting admins; add honest admin panel retry fallback

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Prevent baseball onboarding from overwriting existing DB admins during users-row writes.
• Make admin panel error fallback honest (“temporarily unavailable”) and add a real retry.
• Add tests to lock copy + retry behavior and prevent regressions.
Diagram

graph TD
  OB["Baseball onboarding"] --> ENS["ensureUserRowPreservingAdmin()"] --> DB[("public.users")]
  DB --> RPC["Admin rollup RPCs"] --> AP["Admin page (RSC)"]
  AU["Admin user"] --> AP --> BND["PanelBoundary"] --> ST["PanelStale"] --> RTY["Retry button"] --> REF["router.refresh()"] --> AP

  subgraph Legend
    direction LR
    _svc[Service/UI] ~~~ _fn["Function/file"] ~~~ _db[(Database)]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. DB-level guardrail (trigger/constraint) to prevent admin demotion
  • ➕ Enforces invariants regardless of which code path uses service_role
  • ➕ Prevents future regressions from new upsert sites
  • ➖ More DB surface area and migration overhead
  • ➖ Must carefully handle legitimate admin role transitions (if any) and break-glass operations
2. Introduce an is_super_admin table/function as the only gate
  • ➕ Single explicit super-admin list avoids relying on mutable users.role
  • ➕ Can be audited independently of onboarding flows
  • ➖ Adds indirection and operational overhead for a single current super-admin
  • ➖ Doesn’t remove the need to keep app-layer allowlist and DB logic aligned unless fully migrated
3. Always use ignoreDuplicates upserts (never update existing rows)
  • ➕ Simplest way to avoid overwriting sensitive fields with service_role
  • ➖ Breaks legitimate player↔coach conversions that onboarding needs
  • ➖ Leaves existing rows stale (email/role) without an explicit update path

Recommendation: The PR’s approach is the best near-term fix: it preserves the DB’s admin gate while still allowing player↔coach transitions, and it directly addresses the known failure mode (service_role upsert clobber). If this system grows beyond a single super-admin or more service_role writers appear, consider adding a DB-level guardrail to make admin demotion impossible regardless of application code.

Files changed (4) +149 / -18

Bug fix (3) +99 / -15
PanelBoundary.tsxAdd per-panel retry that refreshes RSC and resets the error boundary +45/-6

Add per-panel retry that refreshes RSC and resets the error boundary

• Introduces a retry button that calls router.refresh() and resets the boundary state. The boundary subtree is remounted on retry via a key increment to avoid reusing a poisoned tree.

src/app/admin/_components/PanelBoundary.tsx

PanelStates.tsxMake stale state copy honest and support an optional action area +15/-2

Make stale state copy honest and support an optional action area

• Updates PanelStale messaging to “temporarily unavailable” to match actual behavior (no cached data rendered). Adds an optional action slot for retry controls while preserving error display.

src/app/admin/_components/PanelStates.tsx

onboarding.tsPreserve existing admin role when creating/updating users during onboarding +39/-7

Preserve existing admin role when creating/updating users during onboarding

• Adds ensureUserRowPreservingAdmin() to read existing role and avoid overwriting an admin with coach/player during service_role writes. Replaces prior upserts in both coach onboarding completion and baseball signup with the preserving helper.

src/app/baseball/actions/onboarding.ts

Tests (1) +50 / -3
panel-boundary.test.tsxTest honest stale copy and retry behavior via mocked router.refresh +50/-3

Test honest stale copy and retry behavior via mocked router.refresh

• Adds tests ensuring the UI never claims “last known data”, verifies the new “temporarily unavailable” copy, and asserts retry triggers router.refresh and remounts to recover from transient failures.

src/app/admin/_components/tests/panel-boundary.test.tsx

@coderabbitai coderabbitai Bot added the security Auth, secrets, RLS, PII, webhooks label Jul 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cb302776be

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const retry = useCallback(() => {
startTransition(() => {
router.refresh();
onReset();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reset the boundary only after fresh data is available

For the async server panels this boundary wraps, router.refresh() only schedules a new RSC payload; calling onReset() immediately re-renders the same throwing children before that payload has arrived, putting the new keyed boundary back into its error state. When the refreshed payload later succeeds, the boundary still renders the fallback because its error state is already set, so a transient admin-panel RPC/Sentry failure can remain stuck on “temporarily unavailable” until a full page reload.

Useful? React with 👍 / 👎.

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (4) 📜 Skill insights (0)

Context used
✅ Compliance rules (platform): 93 rules

Grey Divider


Action required

1. src/app/admin missing registry mapping 📘 Rule violation ⚙ Maintainability
Description
The PR modifies admin UI code under src/app/admin/, but memory/registry.yml does not map that
route tree to any feature entry. This violates the requirement to update the feature registry when
touching unmapped features.
Code

src/app/admin/_components/PanelBoundary.tsx[R1-6]

'use client';

-import { Component, Suspense, type ReactNode } from 'react';
+import { Component, Suspense, useCallback, useState, useTransition, type ReactNode } from 'react';
+import { useRouter } from 'next/navigation';
import { SkeletonStat } from '@/components/fairway';
import { PanelStale } from './PanelStates';
Relevance

⭐⭐⭐ High

Team has enforced updating memory/registry.yml when adding/touching unmapped features (PR #296).

PR-#296

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1519254 requires registry updates when touching features that are not mapped. The
modified code is under src/app/admin/**, while memory/registry.yml shows admin mappings only for
src/app/golf/admin/** and contains no src/app/admin/** entry.

Rule 1519254: Update feature registry mappings when touching unmapped features
src/app/admin/_components/PanelBoundary.tsx[1-6]
memory/registry.yml[955-985]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Feature registry does not include mappings for the modified `src/app/admin/**` route tree.

## Issue Context
`memory/registry.yml` currently maps admin to `src/app/golf/admin/**`, but nothing covers `src/app/admin/**`.

## Fix Focus Areas
- memory/registry.yml[955-985]
- src/app/admin/_components/PanelBoundary.tsx[1-6]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Unprefixed users table access 📘 Rule violation ⚙ Maintainability
Description
The new onboarding helper queries and writes the users table via admin.from('users'), which
violates the required golf_/baseball_ table-prefix convention. This can reintroduce ambiguous
cross-domain table usage and breaks the naming policy enforced by the checklist.
Code

src/app/baseball/actions/onboarding.ts[R73-88]

+  const { data: existing, error: readError } = await admin
+    .from('users')
+    .select('role')
+    .eq('id', userId)
+    .maybeSingle();
+  if (readError) return { error: readError };
+
+  if (!existing) {
+    const { error } = await admin
+      .from('users')
+      .upsert({ id: userId, email, role }, { onConflict: 'id', ignoreDuplicates: true });
+    return { error };
+  }
+  if (existing.role === 'admin' || existing.role === role) return { error: null };
+
+  const { error } = await admin.from('users').update({ role }).eq('id', userId);
Relevance

⭐⭐ Medium

No historical evidence on sport-prefix rule for shared public.users; prefix enforcement seen mostly
on sport tables.

PR-#574

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1519306 requires sport-prefixed table names. The added helper performs
select/upsert/update against users, which is an unprefixed table identifier.

Rule 1519306: Enforce sport-specific table name prefixes
src/app/baseball/actions/onboarding.ts[73-89]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New code references the unprefixed `users` table, but compliance requires all table identifiers to start with `golf_` or `baseball_`.

## Issue Context
This was introduced in the new `ensureUserRowPreservingAdmin()` helper.

## Fix Focus Areas
- src/app/baseball/actions/onboarding.ts[73-88]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Admin demotion race window 🐞 Bug ⛨ Security
Description
ensureUserRowPreservingAdmin() does a separate read of users.role and then an unconditional UPDATE
by id; if the row becomes role='admin' between those statements, the service_role update can still
overwrite it back to 'coach'/'player'. This undermines the PR’s goal (preventing admin lockout)
because service_role writes bypass the self-escalation trigger guard.
Code

src/app/baseball/actions/onboarding.ts[R73-89]

+  const { data: existing, error: readError } = await admin
+    .from('users')
+    .select('role')
+    .eq('id', userId)
+    .maybeSingle();
+  if (readError) return { error: readError };
+
+  if (!existing) {
+    const { error } = await admin
+      .from('users')
+      .upsert({ id: userId, email, role }, { onConflict: 'id', ignoreDuplicates: true });
+    return { error };
+  }
+  if (existing.role === 'admin' || existing.role === role) return { error: null };
+
+  const { error } = await admin.from('users').update({ role }).eq('id', userId);
+  return { error };
Relevance

⭐⭐ Medium

No prior review precedent found for TOCTOU/race hardening on role updates; security fixes sometimes
accepted case-by-case.

PR-#574

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The helper reads role first and later performs an UPDATE filtered only by id, which creates a race
window where a newly-admin row can still be overwritten. The DB guard trigger only blocks
self-updates when auth.uid() matches the row id, so it does not protect service_role updates used
here.

src/app/baseball/actions/onboarding.ts[60-89]
supabase/migrations/20260701100000_fix_handle_new_user_role_cast.sql[89-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ensureUserRowPreservingAdmin()` is a TOCTOU (read-then-write) implementation. A concurrent promotion to `role='admin'` can still be overwritten by the subsequent `UPDATE`, because the update has no DB-side guard.

### Issue Context
This helper is intentionally run via a service-role client; service-role writes are not blocked by the self-escalation guard trigger.

### Fix Focus Areas
- src/app/baseball/actions/onboarding.ts[60-90]

### Suggested fix
Implement an **atomic** write that preserves admin at the database level:
- Prefer a single SQL statement (via RPC or `upsert` + server-side function) that does `ON CONFLICT (id) DO UPDATE SET role = CASE WHEN users.role='admin' THEN users.role ELSE EXCLUDED.role END`.
- If you keep app-side logic, at minimum guard the update with `.neq('role','admin')` (or equivalent) and handle the “0 rows updated” case by re-reading the role and returning success when it is `admin`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

4. PanelStale exceeds empty-state spec 📘 Rule violation ✧ Quality
Description
PanelStale renders multiple text blocks (label line plus optional error text) and allows arbitrary
extra content via action, exceeding the allowed empty-state structure. This can lead to
inconsistent empty-state layouts and copy across the app.
Code

src/app/admin/_components/PanelStates.tsx[R28-47]

+export function PanelStale({
+  label,
+  error,
+  action,
+}: {
+  label: string;
+  error?: string;
+  action?: React.ReactNode;
+}) {
+  // Copy must be honest: this state renders when the fetch FAILED and there
+  // is no data below it. It previously claimed "showing last known data",
+  // which was never true (nothing cached is rendered) — flagged in the
+  // 2026-07-03 Mission Control sweep while every panel was down with 42501.
  return (
    <div className="flex flex-col items-center gap-2 rounded-xl bg-fw-warning-bg px-6 py-8 text-center">
      <CloudOff size={20} className="text-fw-warning" aria-hidden />
-      <p className="text-sm font-medium text-warm-800">{label} — showing last known data</p>
+      <p className="text-sm font-medium text-warm-800">{label} — temporarily unavailable</p>
      {error ? <p className="font-fw-mono text-xs text-warm-600">{error}</p> : null}
+      {action}
    </div>
Relevance

⭐⭐ Medium

No clear prior reviews enforcing strict empty-state structure; closest UI-structure nits often vary
by PR.

PR-#304
PR-#729

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1519300 restricts empty state content to icon, title, one-sentence description, and
a single CTA only. PanelStale includes a second conditional paragraph for error and an
open-ended action slot, which violates the constraint.

Rule 1519300: Empty state components must only render icon, title, one-sentence description, and single CTA
src/app/admin/_components/PanelStates.tsx[28-47]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The `PanelStale` empty-state component renders extra content beyond the allowed icon/title/one-sentence description/single CTA structure.

## Issue Context
It currently includes an optional additional error paragraph and an `action` slot that can render arbitrary nodes.

## Fix Focus Areas
- src/app/admin/_components/PanelStates.tsx[28-47]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Users email not refreshed 🐞 Bug ≡ Correctness
Description
ensureUserRowPreservingAdmin() only writes email during the insert path, and its existing-row path
either early-returns or updates only role, so public.users.email can become stale. Since the app
supports auth email changes and other flows send notifications using public.users.email, this can
cause notifications to be sent to the wrong address.
Code

src/app/baseball/actions/onboarding.ts[R80-89]

+  if (!existing) {
+    const { error } = await admin
+      .from('users')
+      .upsert({ id: userId, email, role }, { onConflict: 'id', ignoreDuplicates: true });
+    return { error };
+  }
+  if (existing.role === 'admin' || existing.role === role) return { error: null };
+
+  const { error } = await admin.from('users').update({ role }).eq('id', userId);
+  return { error };
Relevance

⭐⭐ Medium

No historical precedent found on keeping public.users.email synced with auth email; unclear team
expectation.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Settings pages update the auth email, but the helper does not propagate email changes to
public.users for existing rows. Other code (messaging notifications) pulls recipient emails from
public.users, so stale values directly affect outbound email targeting.

src/app/baseball/actions/onboarding.ts[73-89]
src/app/golf/(dashboard)/dashboard/settings/page.tsx[808-831]
src/app/actions/messages.ts[443-470]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
When a `users` row already exists, `ensureUserRowPreservingAdmin()` does not update `email` (and when it updates a role it still only updates `role`). This regresses from the previous upsert behavior which refreshed `email` on conflict.

### Issue Context
The product allows changing auth email via settings, and email notifications query `public.users.email`.

### Fix Focus Areas
- src/app/baseball/actions/onboarding.ts[73-89]

### Suggested fix
Ensure `email` is refreshed for existing rows:
- If `existing.role === 'admin'`, keep role as admin but still allow email update (unless there’s a deliberate reason not to).
- For non-admin rows, update both `role` (when needed) and `email` (when it differs or unconditionally).
- Consider folding this into the same atomic/guarded update proposed in the admin-preservation fix.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

6. Server action outside src/app/actions 📘 Rule violation ⌂ Architecture
Description
A server action module ('use server') was modified but lives under src/app/baseball/actions/
instead of src/app/actions/. This violates the required server-action file placement convention
and makes action discovery/governance inconsistent.
Code

src/app/baseball/actions/onboarding.ts[R60-90]

+/**
+ * Ensure the public.users row exists with the requested self-service role,
+ * WITHOUT demoting an existing 'admin'. A plain upsert here runs as
+ * service_role (bypasses the self-escalation trigger) and clobbered
+ * admin@helmsportslabs.com down to 'coach' on 2026-07-03, locking the only
+ * admin out of /golf/admin. player<->coach conversion stays allowed.
+ */
+async function ensureUserRowPreservingAdmin(
+  admin: ReturnType<typeof createAdminClient>,
+  userId: string,
+  email: string,
+  role: 'coach' | 'player',
+): Promise<{ error: unknown }> {
+  const { data: existing, error: readError } = await admin
+    .from('users')
+    .select('role')
+    .eq('id', userId)
+    .maybeSingle();
+  if (readError) return { error: readError };
+
+  if (!existing) {
+    const { error } = await admin
+      .from('users')
+      .upsert({ id: userId, email, role }, { onConflict: 'id', ignoreDuplicates: true });
+    return { error };
+  }
+  if (existing.role === 'admin' || existing.role === role) return { error: null };
+
+  const { error } = await admin.from('users').update({ role }).eq('id', userId);
+  return { error };
+}
Relevance

⭐ Low

Repo commonly keeps server actions under feature folders like src/app/baseball/actions (e.g. PR
#579).

PR-#579
PR-#574

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1519234 requires server action implementations to live under src/app/actions/.
The modified file is a server action module (starts with 'use server') but resides in
src/app/baseball/actions/.

Rule 1519234: Place Next.js server actions in src/app/actions directory
src/app/baseball/actions/onboarding.ts[1-5]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
This server action module is not located under `src/app/actions/` as required.

## Issue Context
The file contains a top-level `'use server'` directive and exports server actions, but is located at `src/app/baseball/actions/onboarding.ts`.

## Fix Focus Areas
- src/app/baseball/actions/onboarding.ts[1-6]
- src/app/baseball/actions/onboarding.ts[60-90]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. User write errors ignored 🐞 Bug ☼ Reliability
Description
completeBaseballSignup() calls ensureUserRowPreservingAdmin() but discards its returned error, so
failures to read/insert/update the users row are silently ignored. This can leave onboarding in a
partially-written state without any log signal or user-facing error at the point of failure.
Code

src/app/baseball/actions/onboarding.ts[R486-489]

+  // Ensure user record exists (never demotes an existing admin)
+  await ensureUserRowPreservingAdmin(
+    admin, user.id, userEmail, data.role === 'coach' ? 'coach' : 'player',
+  );
Relevance

⭐ Low

Similar “handle/log errors instead of ignoring” suggestions were often rejected as non-blocking (PR
#564, #209).

PR-#564
PR-#209

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The signup flow ignores the helper’s return value, while the coach onboarding flow checks it and
aborts with a logged error, demonstrating the intended handling pattern.

src/app/baseball/actions/onboarding.ts[486-500]
src/app/baseball/actions/onboarding.ts[170-177]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`completeBaseballSignup()` awaits `ensureUserRowPreservingAdmin(...)` but does not check the returned `{ error }`.

### Issue Context
The same helper call in `runCompleteCoachOnboardingCore()` already handles errors by logging and returning a safe message.

### Fix Focus Areas
- src/app/baseball/actions/onboarding.ts[486-490]

### Suggested fix
Capture and handle the error similarly to the coach onboarding path:
- `const { error: userError } = await ensureUserRowPreservingAdmin(...);`
- If `userError`, `logServerError(...)` with `describeDbError(userError)` and return `{ success:false, error:'Unable to set up your account. Please try again.' }` (or equivalent).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +73 to +88
const { data: existing, error: readError } = await admin
.from('users')
.select('role')
.eq('id', userId)
.maybeSingle();
if (readError) return { error: readError };

if (!existing) {
const { error } = await admin
.from('users')
.upsert({ id: userId, email, role }, { onConflict: 'id', ignoreDuplicates: true });
return { error };
}
if (existing.role === 'admin' || existing.role === role) return { error: null };

const { error } = await admin.from('users').update({ role }).eq('id', userId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Unprefixed users table access 📘 Rule violation ⚙ Maintainability

The new onboarding helper queries and writes the users table via admin.from('users'), which
violates the required golf_/baseball_ table-prefix convention. This can reintroduce ambiguous
cross-domain table usage and breaks the naming policy enforced by the checklist.
Agent Prompt
## Issue description
New code references the unprefixed `users` table, but compliance requires all table identifiers to start with `golf_` or `baseball_`.

## Issue Context
This was introduced in the new `ensureUserRowPreservingAdmin()` helper.

## Fix Focus Areas
- src/app/baseball/actions/onboarding.ts[73-88]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +28 to 47
export function PanelStale({
label,
error,
action,
}: {
label: string;
error?: string;
action?: React.ReactNode;
}) {
// Copy must be honest: this state renders when the fetch FAILED and there
// is no data below it. It previously claimed "showing last known data",
// which was never true (nothing cached is rendered) — flagged in the
// 2026-07-03 Mission Control sweep while every panel was down with 42501.
return (
<div className="flex flex-col items-center gap-2 rounded-xl bg-fw-warning-bg px-6 py-8 text-center">
<CloudOff size={20} className="text-fw-warning" aria-hidden />
<p className="text-sm font-medium text-warm-800">{label} — showing last known data</p>
<p className="text-sm font-medium text-warm-800">{label} — temporarily unavailable</p>
{error ? <p className="font-fw-mono text-xs text-warm-600">{error}</p> : null}
{action}
</div>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. panelstale exceeds empty-state spec 📘 Rule violation ✧ Quality

PanelStale renders multiple text blocks (label line plus optional error text) and allows arbitrary
extra content via action, exceeding the allowed empty-state structure. This can lead to
inconsistent empty-state layouts and copy across the app.
Agent Prompt
## Issue description
The `PanelStale` empty-state component renders extra content beyond the allowed icon/title/one-sentence description/single CTA structure.

## Issue Context
It currently includes an optional additional error paragraph and an `action` slot that can render arbitrary nodes.

## Fix Focus Areas
- src/app/admin/_components/PanelStates.tsx[28-47]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines 1 to 6
'use client';

import { Component, Suspense, type ReactNode } from 'react';
import { Component, Suspense, useCallback, useState, useTransition, type ReactNode } from 'react';
import { useRouter } from 'next/navigation';
import { SkeletonStat } from '@/components/fairway';
import { PanelStale } from './PanelStates';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. src/app/admin missing registry mapping 📘 Rule violation ⚙ Maintainability

The PR modifies admin UI code under src/app/admin/, but memory/registry.yml does not map that
route tree to any feature entry. This violates the requirement to update the feature registry when
touching unmapped features.
Agent Prompt
## Issue description
Feature registry does not include mappings for the modified `src/app/admin/**` route tree.

## Issue Context
`memory/registry.yml` currently maps admin to `src/app/golf/admin/**`, but nothing covers `src/app/admin/**`.

## Fix Focus Areas
- memory/registry.yml[955-985]
- src/app/admin/_components/PanelBoundary.tsx[1-6]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +73 to +89
const { data: existing, error: readError } = await admin
.from('users')
.select('role')
.eq('id', userId)
.maybeSingle();
if (readError) return { error: readError };

if (!existing) {
const { error } = await admin
.from('users')
.upsert({ id: userId, email, role }, { onConflict: 'id', ignoreDuplicates: true });
return { error };
}
if (existing.role === 'admin' || existing.role === role) return { error: null };

const { error } = await admin.from('users').update({ role }).eq('id', userId);
return { error };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

5. Admin demotion race window 🐞 Bug ⛨ Security

ensureUserRowPreservingAdmin() does a separate read of users.role and then an unconditional UPDATE
by id; if the row becomes role='admin' between those statements, the service_role update can still
overwrite it back to 'coach'/'player'. This undermines the PR’s goal (preventing admin lockout)
because service_role writes bypass the self-escalation trigger guard.
Agent Prompt
### Issue description
`ensureUserRowPreservingAdmin()` is a TOCTOU (read-then-write) implementation. A concurrent promotion to `role='admin'` can still be overwritten by the subsequent `UPDATE`, because the update has no DB-side guard.

### Issue Context
This helper is intentionally run via a service-role client; service-role writes are not blocked by the self-escalation guard trigger.

### Fix Focus Areas
- src/app/baseball/actions/onboarding.ts[60-90]

### Suggested fix
Implement an **atomic** write that preserves admin at the database level:
- Prefer a single SQL statement (via RPC or `upsert` + server-side function) that does `ON CONFLICT (id) DO UPDATE SET role = CASE WHEN users.role='admin' THEN users.role ELSE EXCLUDED.role END`.
- If you keep app-side logic, at minimum guard the update with `.neq('role','admin')` (or equivalent) and handle the “0 rows updated” case by re-reading the role and returning success when it is `admin`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +80 to +89
if (!existing) {
const { error } = await admin
.from('users')
.upsert({ id: userId, email, role }, { onConflict: 'id', ignoreDuplicates: true });
return { error };
}
if (existing.role === 'admin' || existing.role === role) return { error: null };

const { error } = await admin.from('users').update({ role }).eq('id', userId);
return { error };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

7. Users email not refreshed 🐞 Bug ≡ Correctness

ensureUserRowPreservingAdmin() only writes email during the insert path, and its existing-row path
either early-returns or updates only role, so public.users.email can become stale. Since the app
supports auth email changes and other flows send notifications using public.users.email, this can
cause notifications to be sent to the wrong address.
Agent Prompt
### Issue description
When a `users` row already exists, `ensureUserRowPreservingAdmin()` does not update `email` (and when it updates a role it still only updates `role`). This regresses from the previous upsert behavior which refreshed `email` on conflict.

### Issue Context
The product allows changing auth email via settings, and email notifications query `public.users.email`.

### Fix Focus Areas
- src/app/baseball/actions/onboarding.ts[73-89]

### Suggested fix
Ensure `email` is refreshed for existing rows:
- If `existing.role === 'admin'`, keep role as admin but still allow email update (unless there’s a deliberate reason not to).
- For non-admin rows, update both `role` (when needed) and `email` (when it differs or unconditionally).
- Consider folding this into the same atomic/guarded update proposed in the admin-preservation fix.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/admin/_components/PanelBoundary.tsx (1)

78-99: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Focus is silently lost on remount — add a stable focus anchor.

key={attempt} fully unmounts/remounts PanelErrorBoundary on every retry. The "Try again" button (and the whole <section>) that had keyboard focus at click time is destroyed, and nothing refocuses the recovered/still-erroring content. Keyboard and screen-reader users lose their place after every retry with no announcement of success/failure.

Wrap the keyed subtree in a stable container and move focus to it once attempt changes.

♿ Proposed fix to restore focus after remount
-import { Component, Suspense, useCallback, useState, useTransition, type ReactNode } from 'react';
+import { Component, Suspense, useCallback, useEffect, useRef, useState, useTransition, type ReactNode } from 'react';
   const [attempt, setAttempt] = useState(0);
+  const containerRef = useRef<HTMLDivElement>(null);
+
+  useEffect(() => {
+    if (attempt > 0) containerRef.current?.focus();
+  }, [attempt]);
+
   return (
-    <PanelErrorBoundary
-      key={attempt}
-      title={title}
-      onRetryReset={() => setAttempt((n) => n + 1)}
-    >
-      <Suspense fallback={skeleton ?? <SkeletonStat />}>{children}</Suspense>
-    </PanelErrorBoundary>
+    <div ref={containerRef} tabIndex={-1}>
+      <PanelErrorBoundary
+        key={attempt}
+        title={title}
+        onRetryReset={() => setAttempt((n) => n + 1)}
+      >
+        <Suspense fallback={skeleton ?? <SkeletonStat />}>{children}</Suspense>
+      </PanelErrorBoundary>
+    </div>
   );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/_components/PanelBoundary.tsx` around lines 78 - 99, The retry
remount in PanelBoundary is destroying the focused error UI without restoring
focus. Keep the attempt-based remount for PanelErrorBoundary, but add a stable
wrapper/focus anchor around the keyed subtree and, after onRetryReset increments
attempt, move focus to that anchor so keyboard and screen-reader users stay
oriented. Use the PanelBoundary and PanelErrorBoundary structure here to place
the focus target outside the keyed remount and update focus on attempt changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/admin/_components/PanelBoundary.tsx`:
- Around line 21-26: The retry path in PanelBoundary still depends on cached
admin fetches, so router.refresh() alone may replay stale upstream data. Update
the admin fetch flows in sentry-api and vercel-api to bypass cache on retry,
using cache: 'no-store' or a retry-specific revalidation/tag mechanism, and wire
that through the retry handling in PanelBoundary so the refresh forces a fresh
pull.

In `@src/app/baseball/actions/onboarding.ts`:
- Around line 486-489: The completeBaseballSignup flow is ignoring the result
from ensureUserRowPreservingAdmin, so a failed users-row read/write can still
allow the rest of the signup to continue. Update completeBaseballSignup to
capture the helper’s return value and mirror the coach path’s userError handling
by returning a typed failure immediately when the user row cannot be ensured.
Use the ensureUserRowPreservingAdmin call in onboarding.ts as the fix point and
keep the downstream baseball_players insert/coach delegation from running after
that failure.
- Line 88: The role change in the onboarding flow still has a TOCTOU gap in the
`admin.from('users').update(...)` path inside `onboardUser`; add the guard
directly to the `UPDATE` by keeping the `userId` filter and excluding `role =
'admin'` in the same write. Then handle the case where the guarded update
affects zero rows as a conflict/error so a late role change cannot demote the
last admin.

---

Outside diff comments:
In `@src/app/admin/_components/PanelBoundary.tsx`:
- Around line 78-99: The retry remount in PanelBoundary is destroying the
focused error UI without restoring focus. Keep the attempt-based remount for
PanelErrorBoundary, but add a stable wrapper/focus anchor around the keyed
subtree and, after onRetryReset increments attempt, move focus to that anchor so
keyboard and screen-reader users stay oriented. Use the PanelBoundary and
PanelErrorBoundary structure here to place the focus target outside the keyed
remount and update focus on attempt changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a80b0d62-d702-4b4e-b6ff-c2fab80b5e74

📥 Commits

Reviewing files that changed from the base of the PR and between 76959f9 and cb30277.

📒 Files selected for processing (4)
  • src/app/admin/_components/PanelBoundary.tsx
  • src/app/admin/_components/PanelStates.tsx
  • src/app/admin/_components/__tests__/panel-boundary.test.tsx
  • src/app/baseball/actions/onboarding.ts

Comment on lines +21 to +26
const retry = useCallback(() => {
startTransition(() => {
router.refresh();
onReset();
});
}, [router, onReset]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Locate the data-fetching code behind admin panels to confirm cache opt-out
rg -n "cache:\s*'no-store'|cacheTag|cacheLife|fetch\(" src/app/admin --type=ts --type=tsx -C2

Repository: njrini99-code/helmv3

Length of output: 186


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map likely admin files and inspect the retry boundary plus data fetches.
git ls-files 'src/app/admin/**' | sed -n '1,200p'

printf '\n--- PanelBoundary ---\n'
sed -n '1,220p' src/app/admin/_components/PanelBoundary.tsx

printf '\n--- admin file cache/fetch scan ---\n'
rg -n "cache:\\s*'no-store'|cacheTag|cacheLife|revalidatePath|revalidateTag|fetch\\(" src/app/admin -g '*.ts' -g '*.tsx' -C 2

Repository: njrini99-code/helmv3

Length of output: 7338


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- admin root page ---'
sed -n '1,220p' src/app/admin/page.tsx

printf '\n%s\n' '--- admin errors page ---'
sed -n '1,220p' src/app/admin/errors/page.tsx

printf '\n%s\n' '--- admin health page ---'
sed -n '1,220p' src/app/admin/health/page.tsx

printf '\n%s\n' '--- admin deploys page ---'
sed -n '1,220p' src/app/admin/deploys/page.tsx

printf '\n%s\n' '--- admin panel-related fetch scan ---'
rg -n "cache:\\s*'no-store'|revalidateTag|revalidatePath|unstable_noStore|noStore\\(|fetch\\(" src/app/admin/_components src/app/admin -g '*.ts' -g '*.tsx' -C 2

Repository: njrini99-code/helmv3

Length of output: 27930


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- admin data helper cache scan ---'
rg -n "cache:\\s*'no-store'|cache:\\s*'force-cache'|unstable_noStore|noStore\\(|fetch\\(" src/lib/admin -g '*.ts' -g '*.tsx' -C 2

printf '\n%s\n' '--- helper file list ---'
git ls-files 'src/lib/admin/**' | sed -n '1,200p'

Repository: njrini99-code/helmv3

Length of output: 4090


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- sentry api ---'
sed -n '1,220p' src/lib/admin/sentry-api.ts

printf '\n%s\n' '--- vercel api ---'
sed -n '1,240p' src/lib/admin/vercel-api.ts

printf '\n%s\n' '--- admin data helpers with fetch ---'
for f in src/lib/admin/data/overview.ts src/lib/admin/data/triage.ts src/lib/admin/data/errors.ts src/lib/admin/data/feature-health.ts src/lib/admin/data/briefing.ts; do
  printf '\n### %s ###\n' "$f"
  sed -n '1,260p' "$f"
done

Repository: njrini99-code/helmv3

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- PanelBoundary line numbers ---'
nl -ba src/app/admin/_components/PanelBoundary.tsx | sed -n '1,120p'

printf '\n%s\n' '--- sentry-api line numbers ---'
nl -ba src/lib/admin/sentry-api.ts | sed -n '1,140p'

printf '\n%s\n' '--- vercel-api line numbers ---'
nl -ba src/lib/admin/vercel-api.ts | sed -n '1,180p'

Repository: njrini99-code/helmv3

Length of output: 233


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- PanelBoundary line numbers ---'
cat -n src/app/admin/_components/PanelBoundary.tsx | sed -n '1,120p'

printf '\n%s\n' '--- sentry-api line numbers ---'
cat -n src/lib/admin/sentry-api.ts | sed -n '1,140p'

printf '\n%s\n' '--- vercel-api line numbers ---'
cat -n src/lib/admin/vercel-api.ts | sed -n '1,180p'

Repository: njrini99-code/helmv3

Length of output: 15609


Bypass cached admin fetches on retry src/app/admin/_components/PanelBoundary.tsx:21-26 only calls router.refresh(), but the panel data sources still opt into Next revalidation (src/lib/admin/sentry-api.ts:92-96, src/lib/admin/vercel-api.ts:76-79, src/lib/admin/vercel-api.ts:123-125). That can replay the same upstream response for up to 60s/900s after a retry. Use cache: 'no-store' or a retry-specific revalidation/tag path if the button is meant to force a fresh pull.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/admin/_components/PanelBoundary.tsx` around lines 21 - 26, The retry
path in PanelBoundary still depends on cached admin fetches, so router.refresh()
alone may replay stale upstream data. Update the admin fetch flows in sentry-api
and vercel-api to bypass cache on retry, using cache: 'no-store' or a
retry-specific revalidation/tag mechanism, and wire that through the retry
handling in PanelBoundary so the refresh forces a fresh pull.

Source: Path instructions

}
if (existing.role === 'admin' || existing.role === role) return { error: null };

const { error } = await admin.from('users').update({ role }).eq('id', userId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

supabase-js 2.107 update neq filter chaining

💡 Result:

In supabase-js, including version 2.107.0, the.neq filter is fully supported and designed to be chained with other filters [1][2]. There were no breaking changes or specific updates regarding.neq filter chaining in the 2.107.0 release [3][4]. The.neq method is a standard part of the PostgrestFilterBuilder API, which allows you to combine multiple filter conditions sequentially to build complex queries [5][2]. Example of chaining.neq with other filters: const { data, error } = await supabase.from('cities').select('name, country_id').gte('population', 1000).neq('name', 'The Shire') // Chaining.neq.lt('population', 10000); Key points regarding.neq: - It matches rows where the column is not equal to the specified value [1]. - It does not include rows where the column is NULL; for NULL values, use.is(column, null) instead [1]. - It is fully chainable with other filter methods like.select,.eq,.gt,.lt, etc. [5][2].

Citations:


🏁 Script executed:

set -euo pipefail

printf '\n## file map\n'
git ls-files src/app/baseball/actions/onboarding.ts

printf '\n## outline\n'
ast-grep outline src/app/baseball/actions/onboarding.ts --view expanded || true

printf '\n## relevant slice\n'
sed -n '1,220p' src/app/baseball/actions/onboarding.ts | cat -n

printf '\n## search for admin demotion logic\n'
rg -n "only admin|demot|role|self-escalation|admin" src/app/baseball/actions/onboarding.ts src/app/baseball/actions -n || true

Repository: njrini99-code/helmv3

Length of output: 32738


🏁 Script executed:

python3 - <<'PY'
from pathlib import Path
p = Path('src/app/baseball/actions/onboarding.ts')
print(p.exists(), p.stat().st_size if p.exists() else None)
PY

Repository: njrini99-code/helmv3

Length of output: 169


🏁 Script executed:

set -euo pipefail

printf '\n## knowledge map\n'
npm run knowledge:map -- --files src/app/baseball/actions/onboarding.ts

printf '\n## knowledge context\n'
npm run knowledge:context -- --files src/app/baseball/actions/onboarding.ts --task "verify admin-demotion race and update filter semantics in onboarding action"

Repository: njrini99-code/helmv3

Length of output: 1322


🏁 Script executed:

set -euo pipefail

printf '\n## search repo for related logic\n'
rg -n "self-escalation|only admin|last admin|demot|admin.*role|role.*admin" src supabase memory . -g '!**/node_modules/**' || true

printf '\n## package version for supabase-js\n'
node - <<'JS'
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
console.log(pkg.dependencies?.['`@supabase/supabase-js`'] || pkg.devDependencies?.['`@supabase/supabase-js`'] || 'missing');
JS

Repository: njrini99-code/helmv3

Length of output: 50377


🏁 Script executed:

set -euo pipefail

printf '\n## locate users role schema/type\n'
rg -n "users.*role|role.*users|type.*role|enum.*role|role:" src/lib/types supabase memory src -g '!**/node_modules/**' | head -n 200

Repository: njrini99-code/helmv3

Length of output: 24549


Guard the role update itself. src/app/baseball/actions/onboarding.ts:73-88 still has a TOCTOU: if users.role flips to admin after the select, the later update can still demote the only admin. Keep the guard on the UPDATE (.eq('id', userId).neq('role', 'admin')) and treat a zero-row write as a conflict.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/baseball/actions/onboarding.ts` at line 88, The role change in the
onboarding flow still has a TOCTOU gap in the `admin.from('users').update(...)`
path inside `onboardUser`; add the guard directly to the `UPDATE` by keeping the
`userId` filter and excluding `role = 'admin'` in the same write. Then handle
the case where the guarded update affects zero rows as a conflict/error so a
late role change cannot demote the last admin.

Comment on lines +486 to +489
// Ensure user record exists (never demotes an existing admin)
await ensureUserRowPreservingAdmin(
admin, user.id, userEmail, data.role === 'coach' ? 'coach' : 'player',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

completeBaseballSignup discards the helper's { error } — a failed user-row write silently proceeds.

At src/app/baseball/actions/onboarding.ts:486-489 the return value is dropped. If ensureUserRowPreservingAdmin fails (read or write error), execution continues and inserts into baseball_players (or delegates to the coach action) with no backing users row — an inconsistent account state. The coach path at Line 170-174 correctly checks userError; mirror that here. This also violates the action-file rule to surface a typed reason rather than swallowing errors.

🛠️ Proposed fix
-  // Ensure user record exists (never demotes an existing admin)
-  await ensureUserRowPreservingAdmin(
-    admin, user.id, userEmail, data.role === 'coach' ? 'coach' : 'player',
-  );
+  // Ensure user record exists (never demotes an existing admin)
+  const { error: userError } = await ensureUserRowPreservingAdmin(
+    admin, user.id, userEmail, data.role === 'coach' ? 'coach' : 'player',
+  );
+  if (userError) {
+    await logServerError(`[Onboarding] Failed to upsert user: ${describeDbError(userError)}`, { action: 'onboarding.completeBaseballSignup' });
+    return { success: false, error: 'Unable to set up your account. Please try again.' };
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Ensure user record exists (never demotes an existing admin)
await ensureUserRowPreservingAdmin(
admin, user.id, userEmail, data.role === 'coach' ? 'coach' : 'player',
);
// Ensure user record exists (never demotes an existing admin)
const { error: userError } = await ensureUserRowPreservingAdmin(
admin, user.id, userEmail, data.role === 'coach' ? 'coach' : 'player',
);
if (userError) {
await logServerError(`[Onboarding] Failed to upsert user: ${describeDbError(userError)}`, { action: 'onboarding.completeBaseballSignup' });
return { success: false, error: 'Unable to set up your account. Please try again.' };
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/baseball/actions/onboarding.ts` around lines 486 - 489, The
completeBaseballSignup flow is ignoring the result from
ensureUserRowPreservingAdmin, so a failed users-row read/write can still allow
the rest of the signup to continue. Update completeBaseballSignup to capture the
helper’s return value and mirror the coach path’s userError handling by
returning a typed failure immediately when the user row cannot be ensured. Use
the ensureUserRowPreservingAdmin call in onboarding.ts as the fix point and keep
the downstream baseball_players insert/coach delegation from running after that
failure.

Source: Path instructions

njrini99-code added a commit that referenced this pull request Jul 3, 2026
…60703

Consolidated admin-dashboard fix train (supersedes #736#739, includes #734)
@njrini99-code
njrini99-code merged commit cb30277 into main Jul 3, 2026
38 of 41 checks passed
@njrini99-code
njrini99-code deleted the fix/p0-admin-access-model branch July 3, 2026 05:21
njrini99-code pushed a commit that referenced this pull request Jul 3, 2026
…ift guard (#736 follow-up)

While building the Phase 4 drift guard, direct inspection of live admin
RPCs found that PR #736 fixed the symptom (restored the admin row, added
admin_allowlist/is_super_admin()) but never migrated the RPC layer off
the original bug: all 11 get_admin_*_rollup SECURITY DEFINER functions
(6 directly, 5 via the shared __admin_rollup_b_gate() helper) still gate
solely on `users.role = 'admin'`. Bridge's app-layer gate
(requireSuperAdmin()) only checks SUPER_ADMIN_USER_IDS + admin_allowlist
and never looks at users.role, so the original failure mode --
"requireSuperAdmin() still passed, but admin panel RPCs died with
42501" -- is still fully reproducible today by anything that flips
users.role for the allowlisted admin, not just the specific
baseball-onboarding path already patched.

Also found: guard_users_role_self_change() blocked self-*escalation* to
a role outside player/coach, but explicitly allowed 'coach'/'player' as
NEW.role -- meaning it did nothing to stop a super admin's own
onboarding/profile-update flow from silently self-demoting from admin
to coach, which is the literal #736 incident. That specific transition
was never actually blocked.

Migration `admin_rollup_consistent_super_admin_gate` (applied to
production and verified live):
- Every rollup gate now accepts EITHER public.is_super_admin() OR the
  legacy users.role = 'admin' check -- additive only, no currently
  authorized caller loses access (verified pre-change that
  admin_allowlist and users.role='admin' are the same single user).
- guard_users_role_self_change() now also blocks OLD.role = 'admin' ->
  NEW.role IN (coach, player) for allowlisted super admins.

New: scripts/db/check-supabase-drift.mjs (`npm run db:drift:check`) --
read-only guard covering #651 columns, golf drift shape, #728 function
body, #772 linked-lint functions, #732 rate_limits, admin rollup RPC
existence + consistent gating, and the role-demotion guard. Queries
information_schema/pg_proc directly rather than schema_migrations, per
docs/audits/SUPABASE_DRIFT_REPORT_2026-07-03.md's finding that the
migration ledger cannot be trusted alone on this project.

Co-authored-by: Cursor <cursoragent@cursor.com>
njrini99-code added a commit that referenced this pull request Jul 3, 2026
…se + drift guard (#775)

* docs(audits): read-only Supabase drift verification report (#651, #728, #732, #772)

Re-verifies the Bucket A Supabase drift findings from the stabilization
brief directly against live information_schema/pg_proc (not migration
history, per the brief's own guardrail). All of #651's 12 columns,
#728's function body, and #772's two linked-lint functions are already
resolved in production as of this pass. #732 has no first-party
public.rate_limits reference; reclassified as needing log correlation
instead of a schema fix.

Also documents a new, separate finding: local migration filenames and
the remote-applied version numbers are systemically mismatched for
essentially every migration since ~2026-05-26 (193 local-only / 445
remote-only by version, almost entirely 1:1 name-paired), and
npm run check:ledger is not wired into any CI job. Read-only pass only —
no migrations created/applied, no db push/repair/reset run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(admin): close root-cause admin-rollup 42501 gap + add Supabase drift guard (#736 follow-up)

While building the Phase 4 drift guard, direct inspection of live admin
RPCs found that PR #736 fixed the symptom (restored the admin row, added
admin_allowlist/is_super_admin()) but never migrated the RPC layer off
the original bug: all 11 get_admin_*_rollup SECURITY DEFINER functions
(6 directly, 5 via the shared __admin_rollup_b_gate() helper) still gate
solely on `users.role = 'admin'`. Bridge's app-layer gate
(requireSuperAdmin()) only checks SUPER_ADMIN_USER_IDS + admin_allowlist
and never looks at users.role, so the original failure mode --
"requireSuperAdmin() still passed, but admin panel RPCs died with
42501" -- is still fully reproducible today by anything that flips
users.role for the allowlisted admin, not just the specific
baseball-onboarding path already patched.

Also found: guard_users_role_self_change() blocked self-*escalation* to
a role outside player/coach, but explicitly allowed 'coach'/'player' as
NEW.role -- meaning it did nothing to stop a super admin's own
onboarding/profile-update flow from silently self-demoting from admin
to coach, which is the literal #736 incident. That specific transition
was never actually blocked.

Migration `admin_rollup_consistent_super_admin_gate` (applied to
production and verified live):
- Every rollup gate now accepts EITHER public.is_super_admin() OR the
  legacy users.role = 'admin' check -- additive only, no currently
  authorized caller loses access (verified pre-change that
  admin_allowlist and users.role='admin' are the same single user).
- guard_users_role_self_change() now also blocks OLD.role = 'admin' ->
  NEW.role IN (coach, player) for allowlisted super admins.

New: scripts/db/check-supabase-drift.mjs (`npm run db:drift:check`) --
read-only guard covering #651 columns, golf drift shape, #728 function
body, #772 linked-lint functions, #732 rate_limits, admin rollup RPC
existence + consistent gating, and the role-demotion guard. Queries
information_schema/pg_proc directly rather than schema_migrations, per
docs/audits/SUPABASE_DRIFT_REPORT_2026-07-03.md's finding that the
migration ledger cannot be trusted alone on this project.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(db): harden Supabase drift guard

---------

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
njrini99-code added a commit that referenced this pull request Jul 3, 2026
Phase 14 of the HelmV3 stabilization brief — final deliverable.
Classifies every issue named in the brief against evidence gathered
this session. No issues closed automatically (recommendations only,
per the brief's guardrail).

Headline findings:
- #516 (hardcoded service-role key) was marked closed but was NOT
  actually fixed — reopened in spirit via PR #774, key rotation is
  still an outstanding manual action.
- #651, #728, and both #772 linked-lint findings verified resolved
  directly against production.
- #736's "prevent recurrence" criterion was not actually met until
  this pass (PR #775) — the RPC layer never migrated off the gate
  model that caused the original incident.
- #390 and #388 (CI runbook issues) were already fully closed and
  verified accurate — no new work needed.
- #477, #442/#443, #415 gained regression coverage this pass (PR
  #776); #406 and true #442 concurrency are blocked on Docker not
  being available in this environment.

Full PR list, dependency-PR triage (2 merged, 1 blocked on a real
Review Gate failure, 6 left for individual review), and everything
explicitly out of scope are listed at the bottom.

Co-authored-by: Fable Integrator <fable@helm.local>
Co-authored-by: Cursor <cursoragent@cursor.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

security Auth, secrets, RLS, PII, webhooks

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant