# v7.0 Phase 5.1 — Members management + RBAC enforcement Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Ship `/dashboard/admin/members` (invite/role-change/remove) + `/dashboard/admin/settings` (org name) for Org-tier customers. All writes go through 4 SECURITY DEFINER RPCs that hold FOR UPDATE locks for atomic count + write + audit.

**Architecture:** Route handlers thin; RPCs own authz + locking + last-owner check + audit. REVOKE FROM authenticated; service-role-only RPC execution prevents direct-client spoofing. Cookie-auth via `@supabase/ssr`; assertSameOrigin guards all mutating routes.

**Tech Stack:** Next.js 16 App Router (apps/web), React 19, Supabase Postgres + RLS + RPCs, vitest, @testing-library/react. NO new env vars.

**Skip Vercel preview deploy verification — agent env doesn't have Vercel access; CI does the real verification.** This deviates from local-end-to-end ship; Phase 1 lesson learned.

---

## File Structure

### New backend files
- `data/deltas/20260508140000_phase5_1_member_rpcs.sql` — 4 RPCs + REVOKE/GRANT
- `apps/web/lib/dashboard/membership-guard.ts` — `resolveCallerForOrg()` shared helper, error-mapping `mapPostgresError()`
- `apps/web/app/api/dashboard/orgs/[orgId]/members/route.ts` — GET list
- `apps/web/app/api/dashboard/orgs/[orgId]/members/invite/route.ts` — POST
- `apps/web/app/api/dashboard/orgs/[orgId]/members/[userId]/route.ts` — PATCH role + DELETE
- `apps/web/app/api/dashboard/orgs/[orgId]/route.ts` — PATCH org name

### New frontend files
- `apps/web/app/dashboard/admin/layout.tsx` — sidebar + admin gate (404 if no admin/owner membership anywhere)
- `apps/web/app/dashboard/admin/members/page.tsx` — server component, lists members
- `apps/web/app/dashboard/admin/settings/page.tsx` — server component, owner-only
- `apps/web/components/admin/MembersList.tsx` — client component, table
- `apps/web/components/admin/InviteMemberForm.tsx` — client
- `apps/web/components/admin/OrgSettingsForm.tsx` — client
- `apps/web/components/admin/RoleSelector.tsx` — client; disables transitions caller can't perform

### New test files
- `apps/web/__tests__/api/dashboard/orgs/members-list.test.ts` — Tests 1-3
- `apps/web/__tests__/api/dashboard/orgs/members-invite.test.ts` — Tests 4-10, 30
- `apps/web/__tests__/api/dashboard/orgs/members-patch-role.test.ts` — Tests 11-18
- `apps/web/__tests__/api/dashboard/orgs/members-delete.test.ts` — Tests 19-24
- `apps/web/__tests__/api/dashboard/orgs/org-update.test.ts` — Tests 25-29
- `apps/web/__tests__/api/dashboard/orgs/concurrency.test.ts` — Test 31 (FOR UPDATE proof)
- `apps/web/__tests__/api/dashboard/orgs/rpc-privilege.test.ts` — Test 31b (direct-RPC REVOKE proof)
- `apps/web/__tests__/integration/admin-pages.test.tsx` — Tests 32-35

### Modified
- `apps/web/app/dashboard/layout.tsx` — add "Admin" link visible when caller has admin/owner anywhere
- `apps/web/__tests__/_helpers/supabase-stub.ts` — add `.rpc('invite_member' | 'change_member_role' | 'remove_member' | 'update_org_name', args)` handlers; add stub for `permission denied` error code (42501)
- `package.json` — version bump `6.3.0-pre.6` → `6.3.0-pre.7`
- `CHANGELOG.md`
- `docs/v6/migration-guide.md` — note Org-tier admin surface

---

## Task 0: Pull spec from spec PR branch

**Files:**
- Create: `docs/specs/v7.0-phase5.1-members-rbac.md` (from spec branch)

- [ ] **Step 1: Pull spec verbatim**

```bash
git fetch origin spec/v7.0-phase5.1-members-rbac
git checkout origin/spec/v7.0-phase5.1-members-rbac -- docs/specs/v7.0-phase5.1-members-rbac.md
git add docs/specs/v7.0-phase5.1-members-rbac.md
git commit -m "docs: pull Phase 5.1 spec from PR #126 for impl reference"
```

This avoids the Phase 2.3 spec-recreation issue.

---

## Task 1: Migration — 4 SECURITY DEFINER RPCs

**Files:**
- Create: `data/deltas/20260508140000_phase5_1_member_rpcs.sql`

- [ ] **Step 1: Write the migration**

```sql
-- Phase 5.1 — Members management RPCs.
--
-- All writes to memberships + organizations route through these
-- SECURITY DEFINER functions for:
--   1. Atomic FOR UPDATE locking on memberships rows (prevents
--      last-owner TOCTOU race between count check and write)
--   2. Centralized authorization (no RLS-bypass via service-role from
--      route handlers — handlers just translate SQLSTATE → HTTP)
--   3. Same-transaction audit_events writes (audit can never lag the
--      membership change)
--
-- Codex pass 2 CRITICAL: REVOKE FROM authenticated; GRANT service_role
-- ONLY. Routes call via createServiceRoleClient() with p_caller_user_id
-- derived from cookie-verified getUser(). Direct authenticated RPC calls
-- get permission denied (42501) at the GRANT level.

-- ============================================================================
-- invite_member
-- ============================================================================
CREATE OR REPLACE FUNCTION public.invite_member(
  p_caller_user_id uuid,
  p_org_id uuid,
  p_invitee_email text,
  p_role text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
  v_caller_role text;
  v_invitee_user_id uuid;
  v_existing memberships%ROWTYPE;
  v_membership memberships%ROWTYPE;
  v_previous_status text;
BEGIN
  -- Validate role argument.
  IF p_role NOT IN ('member', 'admin') THEN
    RAISE EXCEPTION 'bad_role' USING ERRCODE = 'P0001';
  END IF;

  -- Acquire lock FIRST (codex pass 2 CRITICAL #2 — lock before authorize).
  PERFORM 1 FROM memberships
    WHERE organization_id = p_org_id
    FOR UPDATE;

  -- Re-read caller role from locked rows.
  SELECT role INTO v_caller_role
    FROM memberships
   WHERE organization_id = p_org_id
     AND user_id = p_caller_user_id
     AND status = 'active';
  IF v_caller_role IS NULL OR v_caller_role NOT IN ('admin', 'owner') THEN
    RAISE EXCEPTION 'not_admin' USING ERRCODE = 'P0001';
  END IF;

  -- Resolve invitee. Codex pass 1 WARNING — exact lower() comparison.
  SELECT id INTO v_invitee_user_id
    FROM auth.users
   WHERE lower(email) = lower(trim(p_invitee_email))
   LIMIT 1;
  IF v_invitee_user_id IS NULL THEN
    RAISE EXCEPTION 'user_not_found' USING ERRCODE = 'P0001';
  END IF;

  -- Look up existing membership.
  SELECT * INTO v_existing
    FROM memberships
   WHERE organization_id = p_org_id
     AND user_id = v_invitee_user_id;

  IF FOUND AND v_existing.status = 'active' THEN
    RAISE EXCEPTION 'already_member' USING ERRCODE = 'P0001';
  END IF;

  IF FOUND THEN
    -- Reactivation path.
    v_previous_status := v_existing.status;
    UPDATE memberships
       SET status = 'active',
           role = p_role,
           joined_at = NOW()
     WHERE id = v_existing.id
    RETURNING * INTO v_membership;
  ELSE
    v_previous_status := NULL;
    INSERT INTO memberships (organization_id, user_id, role, status, joined_at)
      VALUES (p_org_id, v_invitee_user_id, p_role, 'active', NOW())
    RETURNING * INTO v_membership;
  END IF;

  -- Audit event in same transaction (codex pass 1 WARNING #2).
  PERFORM audit.append(
    p_org_id,
    p_caller_user_id,
    'org.member.invited',
    'membership',
    v_membership.id,
    jsonb_build_object('inviteeUserId', v_invitee_user_id, 'role', p_role, 'previousStatus', v_previous_status)
  );

  RETURN jsonb_build_object('membership', to_jsonb(v_membership), 'noop', false);
END;
$$;

-- ============================================================================
-- change_member_role
-- ============================================================================
CREATE OR REPLACE FUNCTION public.change_member_role(
  p_caller_user_id uuid,
  p_org_id uuid,
  p_target_user_id uuid,
  p_new_role text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
  v_caller_role text;
  v_target memberships%ROWTYPE;
  v_owner_count int;
  v_membership memberships%ROWTYPE;
  v_old_role text;
BEGIN
  IF p_new_role NOT IN ('member', 'admin', 'owner') THEN
    RAISE EXCEPTION 'bad_role' USING ERRCODE = 'P0001';
  END IF;

  -- Lock all org memberships first.
  PERFORM 1 FROM memberships
    WHERE organization_id = p_org_id
    FOR UPDATE;

  -- Re-read caller role.
  SELECT role INTO v_caller_role
    FROM memberships
   WHERE organization_id = p_org_id
     AND user_id = p_caller_user_id
     AND status = 'active';
  IF v_caller_role IS NULL THEN
    RAISE EXCEPTION 'not_admin' USING ERRCODE = 'P0001';
  END IF;

  -- Look up target.
  SELECT * INTO v_target
    FROM memberships
   WHERE organization_id = p_org_id
     AND user_id = p_target_user_id
     AND status = 'active';
  IF NOT FOUND THEN
    RAISE EXCEPTION 'target_not_member' USING ERRCODE = 'P0001';
  END IF;

  -- Authorization (matrix from spec).
  IF v_caller_role = 'admin' THEN
    -- Admin can manage member↔admin only. Cannot touch owner.
    IF v_target.role = 'owner' OR p_new_role = 'owner' THEN
      RAISE EXCEPTION 'role_transition' USING ERRCODE = 'P0001';
    END IF;
  ELSIF v_caller_role = 'owner' THEN
    -- Owner can do any transition subject to last_owner.
    NULL;
  ELSE
    RAISE EXCEPTION 'not_admin' USING ERRCODE = 'P0001';
  END IF;

  -- Last-owner safeguard.
  IF v_target.role = 'owner' AND p_new_role <> 'owner' THEN
    SELECT count(*) INTO v_owner_count
      FROM memberships
     WHERE organization_id = p_org_id
       AND role = 'owner'
       AND status = 'active';
    IF v_owner_count <= 1 THEN
      RAISE EXCEPTION 'last_owner' USING ERRCODE = 'P0001';
    END IF;
  END IF;

  -- Idempotent same-role.
  IF v_target.role = p_new_role THEN
    RETURN jsonb_build_object('membership', to_jsonb(v_target), 'noop', true);
  END IF;

  v_old_role := v_target.role;
  UPDATE memberships SET role = p_new_role
    WHERE id = v_target.id
  RETURNING * INTO v_membership;

  PERFORM audit.append(
    p_org_id,
    p_caller_user_id,
    'org.member.role_changed',
    'membership',
    v_membership.id,
    jsonb_build_object('targetUserId', p_target_user_id, 'oldRole', v_old_role, 'newRole', p_new_role)
  );

  RETURN jsonb_build_object('membership', to_jsonb(v_membership), 'noop', false);
END;
$$;

-- ============================================================================
-- remove_member
-- ============================================================================
CREATE OR REPLACE FUNCTION public.remove_member(
  p_caller_user_id uuid,
  p_org_id uuid,
  p_target_user_id uuid
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
  v_caller_role text;
  v_target memberships%ROWTYPE;
  v_owner_count int;
  v_membership memberships%ROWTYPE;
BEGIN
  PERFORM 1 FROM memberships
    WHERE organization_id = p_org_id
    FOR UPDATE;

  SELECT role INTO v_caller_role
    FROM memberships
   WHERE organization_id = p_org_id
     AND user_id = p_caller_user_id
     AND status = 'active';
  IF v_caller_role IS NULL OR v_caller_role NOT IN ('admin', 'owner') THEN
    RAISE EXCEPTION 'not_admin' USING ERRCODE = 'P0001';
  END IF;

  SELECT * INTO v_target
    FROM memberships
   WHERE organization_id = p_org_id
     AND user_id = p_target_user_id
     AND status = 'active';
  IF NOT FOUND THEN
    RAISE EXCEPTION 'target_not_member' USING ERRCODE = 'P0001';
  END IF;

  -- Removal matrix: admin can remove member only; owner can remove any.
  IF v_caller_role = 'admin' AND v_target.role <> 'member' THEN
    RAISE EXCEPTION 'not_owner' USING ERRCODE = 'P0001';
  END IF;

  -- Last-owner safeguard.
  IF v_target.role = 'owner' THEN
    SELECT count(*) INTO v_owner_count
      FROM memberships
     WHERE organization_id = p_org_id
       AND role = 'owner'
       AND status = 'active';
    IF v_owner_count <= 1 THEN
      RAISE EXCEPTION 'last_owner' USING ERRCODE = 'P0001';
    END IF;
  END IF;

  UPDATE memberships SET status = 'removed'
    WHERE id = v_target.id
  RETURNING * INTO v_membership;

  PERFORM audit.append(
    p_org_id,
    p_caller_user_id,
    'org.member.removed',
    'membership',
    v_membership.id,
    jsonb_build_object('targetUserId', p_target_user_id, 'previousRole', v_target.role)
  );

  RETURN jsonb_build_object('membership', to_jsonb(v_membership), 'noop', false);
END;
$$;

-- ============================================================================
-- update_org_name
-- ============================================================================
CREATE OR REPLACE FUNCTION public.update_org_name(
  p_caller_user_id uuid,
  p_org_id uuid,
  p_new_name text
) RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public, pg_temp
AS $$
DECLARE
  v_caller_role text;
  v_old_name text;
  v_trimmed text;
  v_org organizations%ROWTYPE;
BEGIN
  v_trimmed := trim(coalesce(p_new_name, ''));
  IF length(v_trimmed) < 1 OR length(v_trimmed) > 100 THEN
    RAISE EXCEPTION 'bad_name' USING ERRCODE = 'P0001';
  END IF;

  -- Lock org memberships for caller-role check.
  PERFORM 1 FROM memberships
    WHERE organization_id = p_org_id
    FOR UPDATE;

  SELECT role INTO v_caller_role
    FROM memberships
   WHERE organization_id = p_org_id
     AND user_id = p_caller_user_id
     AND status = 'active';
  -- Codex plan-pass CRITICAL — explicit NULL check.
  -- PL/pgSQL: NULL <> 'owner' evaluates to NULL, not TRUE, so the IF
  -- branch wouldn't fire and a non-member could update org name.
  IF v_caller_role IS NULL OR v_caller_role <> 'owner' THEN
    RAISE EXCEPTION 'not_owner' USING ERRCODE = 'P0001';
  END IF;

  SELECT name INTO v_old_name FROM organizations WHERE id = p_org_id;

  UPDATE organizations SET name = v_trimmed
    WHERE id = p_org_id
  RETURNING * INTO v_org;

  PERFORM audit.append(
    p_org_id,
    p_caller_user_id,
    'org.settings.updated',
    'organization',
    p_org_id,
    jsonb_build_object('field', 'name', 'oldValue', v_old_name, 'newValue', v_trimmed)
  );

  RETURN jsonb_build_object('organization', to_jsonb(v_org), 'noop', false);
END;
$$;

-- ============================================================================
-- Privilege model — codex pass 2 CRITICAL #1
-- Default GRANT EXECUTE TO PUBLIC is implicit. Explicit REVOKE blocks
-- direct authenticated/anon RPC calls; only service-role (used by route
-- handlers) can invoke.
-- ============================================================================
REVOKE ALL ON FUNCTION
  public.invite_member(uuid, uuid, text, text),
  public.change_member_role(uuid, uuid, uuid, text),
  public.remove_member(uuid, uuid, uuid),
  public.update_org_name(uuid, uuid, text)
  FROM PUBLIC, anon, authenticated;

GRANT EXECUTE ON FUNCTION
  public.invite_member(uuid, uuid, text, text),
  public.change_member_role(uuid, uuid, uuid, text),
  public.remove_member(uuid, uuid, uuid),
  public.update_org_name(uuid, uuid, text)
  TO service_role;
```

- [ ] **Step 2: Verify SQL syntactically valid**

Skip running locally — `/migrate` post-merge validates against dev/QA/prod. Lint-only.

- [ ] **Step 3: Commit**

```bash
git add data/deltas/20260508140000_phase5_1_member_rpcs.sql
git commit -m "feat(v7.0-phase5.1): 4 SECURITY DEFINER member RPCs"
```

---

## Task 2: membership-guard helper

**Files:**
- Create: `apps/web/lib/dashboard/membership-guard.ts`

- [ ] **Step 1: Write the failing tests**

Create `apps/web/__tests__/lib/dashboard/membership-guard.test.ts`:

```typescript
import { describe, it, expect } from 'vitest';
import { mapPostgresError } from '@/lib/dashboard/membership-guard';

describe('mapPostgresError', () => {
  it('maps not_admin → 403', () => {
    expect(mapPostgresError({ code: 'P0001', message: 'not_admin' })).toEqual({ status: 403, body: { error: 'not_admin' } });
  });
  it('maps not_owner → 403', () => {
    expect(mapPostgresError({ code: 'P0001', message: 'not_owner' })).toEqual({ status: 403, body: { error: 'not_owner' } });
  });
  it('maps user_not_found → 404', () => {
    expect(mapPostgresError({ code: 'P0001', message: 'user_not_found' })).toEqual({ status: 404, body: { error: 'user_not_found' } });
  });
  it('maps target_not_member → 404', () => {
    expect(mapPostgresError({ code: 'P0001', message: 'target_not_member' })).toEqual({ status: 404, body: { error: 'target_not_member' } });
  });
  it('maps already_member → 409', () => {
    expect(mapPostgresError({ code: 'P0001', message: 'already_member' })).toEqual({ status: 409, body: { error: 'already_member' } });
  });
  it('maps last_owner → 422', () => {
    expect(mapPostgresError({ code: 'P0001', message: 'last_owner' })).toEqual({ status: 422, body: { error: 'last_owner' } });
  });
  it('maps role_transition → 422', () => {
    expect(mapPostgresError({ code: 'P0001', message: 'role_transition' })).toEqual({ status: 422, body: { error: 'role_transition' } });
  });
  it('maps bad_name → 422', () => {
    expect(mapPostgresError({ code: 'P0001', message: 'bad_name' })).toEqual({ status: 422, body: { error: 'bad_name' } });
  });
  it('unmapped P0001 → 500', () => {
    expect(mapPostgresError({ code: 'P0001', message: 'wat' })).toEqual({ status: 500, body: { error: 'internal' } });
  });
  it('non-P0001 → 500', () => {
    expect(mapPostgresError({ code: '23505', message: 'duplicate key' })).toEqual({ status: 500, body: { error: 'internal' } });
  });
});
```

- [ ] **Step 2: Run test (fails — module not yet created)**

```bash
npm test --workspace apps/web -- --run __tests__/lib/dashboard/membership-guard.test.ts
```
Expected: FAIL "cannot find module".

- [ ] **Step 3: Implement membership-guard.ts**

```typescript
// Centralized error mapping + caller resolution for Phase 5.1 routes.
// Codex plan-pass WARNING: shared resolveSessionUserId avoids
// per-route duplication across 5 endpoints.

import { createServerClient as createSsrServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';

export type PgError = { code?: string | null; message?: string | null };

const MAP: Record<string, number> = {
  not_admin: 403,
  not_owner: 403,
  user_not_found: 404,
  target_not_member: 404,
  already_member: 409,
  last_owner: 422,
  role_transition: 422,
  bad_role: 422,
  bad_name: 422,
  bad_email: 422,
};

export function mapPostgresError(err: PgError): { status: number; body: { error: string } } {
  // 42501 = privilege denied (the REVOKE on the RPCs catches direct
  // authenticated calls). Don't leak privilege info — return internal.
  if (err.code !== 'P0001' || !err.message) {
    return { status: 500, body: { error: 'internal' } };
  }
  const status = MAP[err.message];
  if (!status) return { status: 500, body: { error: 'internal' } };
  return { status, body: { error: err.message } };
}

export async function resolveSessionUserId(): Promise<string | null> {
  try {
    const cookieStore = await cookies();
    const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
    const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
    if (!url || !anon) return null;
    const ssr = createSsrServerClient(url, anon, {
      cookies: { getAll: () => cookieStore.getAll(), setAll: () => {} },
    });
    const { data: { user } } = await ssr.auth.getUser();
    return user?.id ?? null;
  } catch { return null; }
}

const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isValidUuid(s: string | undefined | null): s is string {
  return typeof s === 'string' && UUID_RE.test(s);
}
```

- [ ] **Step 4: Run test, verify pass**

```bash
npm test --workspace apps/web -- --run __tests__/lib/dashboard/membership-guard.test.ts
```
Expected: 10 passed.

- [ ] **Step 5: Commit**

```bash
git add apps/web/lib/dashboard/membership-guard.ts apps/web/__tests__/lib/dashboard/membership-guard.test.ts
git commit -m "feat(v7.0-phase5.1): mapPostgresError helper"
```

---

## Task 3: GET /members route + Tests 1-3

**Files:**
- Create: `apps/web/app/api/dashboard/orgs/[orgId]/members/route.ts`
- Create: `apps/web/__tests__/api/dashboard/orgs/members-list.test.ts`

- [ ] **Step 1: Write failing tests**

```typescript
// __tests__/api/dashboard/orgs/members-list.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { randomUUID } from 'crypto';
import { stub } from '../../../_helpers/supabase-stub';

vi.mock('@/lib/supabase/service', () => ({ createServiceRoleClient: () => stub.asClient() }));
let currentUser: { id: string } | null = null;
vi.mock('next/headers', () => ({ cookies: async () => ({ getAll: () => [], get: () => undefined }) }));
vi.mock('@supabase/ssr', () => ({
  createServerClient: () => ({ auth: { getUser: async () => ({ data: { user: currentUser } }) } }),
}));

const { GET } = await import('@/app/api/dashboard/orgs/[orgId]/members/route');

beforeEach(() => {
  stub.reset();
  currentUser = null;
  process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://stub';
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon';
  process.env.SUPABASE_SERVICE_ROLE_KEY = 'stub';
});

function req(orgId: string): Request {
  return new Request(`http://x/api/dashboard/orgs/${orgId}/members`);
}

describe('GET /api/dashboard/orgs/:orgId/members', () => {
  it('test 1: active member sees own org roster with emails', async () => {
    const orgId = randomUUID();
    const me = randomUUID();
    const peer = randomUUID();
    currentUser = { id: me };
    stub.seed('memberships', [
      { id: randomUUID(), organization_id: orgId, user_id: me, role: 'admin', status: 'active', joined_at: new Date().toISOString() },
      { id: randomUUID(), organization_id: orgId, user_id: peer, role: 'member', status: 'active', joined_at: new Date().toISOString() },
    ]);
    stub.seed('auth.users', [
      { id: me, email: 'me@autopilot.dev' },
      { id: peer, email: 'peer@autopilot.dev' },
    ]);
    const r = await GET(req(orgId), { params: { orgId } });
    expect(r.status).toBe(200);
    const body = await r.json();
    expect(body.members.length).toBe(2);
    expect(body.members.find((m: { userId: string }) => m.userId === me).email).toBe('me@autopilot.dev');
  });

  it('test 2: non-member → 404', async () => {
    const orgId = randomUUID();
    currentUser = { id: randomUUID() };
    stub.seed('memberships', [
      { id: randomUUID(), organization_id: orgId, user_id: randomUUID(), role: 'owner', status: 'active', joined_at: new Date().toISOString() },
    ]);
    const r = await GET(req(orgId), { params: { orgId } });
    expect(r.status).toBe(404);
  });

  it('test 3: not signed in → 401', async () => {
    const orgId = randomUUID();
    const r = await GET(req(orgId), { params: { orgId } });
    expect(r.status).toBe(401);
  });
});
```

- [ ] **Step 2: Run, verify FAIL**

```bash
npm test --workspace apps/web -- --run __tests__/api/dashboard/orgs/members-list.test.ts
```
Expected: cannot find module.

- [ ] **Step 3: Implement route**

```typescript
// app/api/dashboard/orgs/[orgId]/members/route.ts
//
// GET /api/dashboard/orgs/:orgId/members — list active members with email lookup.
// Active member of orgId required; non-member → 404 (no enumeration).

import { NextResponse } from 'next/server';
import { createServerClient as createSsrServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { createServiceRoleClient } from '@/lib/supabase/service';

interface RouteParams { params: Promise<{ orgId: string }> | { orgId: string } }

interface MembershipRow {
  id: string;
  organization_id: string;
  user_id: string;
  role: string;
  status: string;
  joined_at: string;
}

async function resolveSessionUserId(): Promise<string | null> {
  try {
    const cookieStore = await cookies();
    const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
    const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
    if (!url || !anon) return null;
    const ssr = createSsrServerClient(url, anon, {
      cookies: { getAll: () => cookieStore.getAll(), setAll: () => {} },
    });
    const { data: { user } } = await ssr.auth.getUser();
    return user?.id ?? null;
  } catch { return null; }
}

export async function GET(_req: Request, { params }: RouteParams): Promise<Response> {
  const p = await Promise.resolve(params) as { orgId: string };
  const callerUserId = await resolveSessionUserId();
  if (!callerUserId) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });

  const supabase = createServiceRoleClient();

  // Cross-org enumeration guard — caller must be active member.
  const { data: callerMembership } = await supabase.from('memberships')
    .select('id')
    .eq('organization_id', p.orgId)
    .eq('user_id', callerUserId)
    .eq('status', 'active')
    .maybeSingle();
  if (!callerMembership) return NextResponse.json({ error: 'not_found' }, { status: 404 });

  const { data: rows } = await supabase.from('memberships')
    .select('id, organization_id, user_id, role, status, joined_at')
    .eq('organization_id', p.orgId)
    .eq('status', 'active');
  const memberRows = (rows as MembershipRow[] | null) ?? [];

  // Codex plan-pass CRITICAL — members list MUST return emails for the
  // UI table. Service-role can read auth.users.
  const userIds = memberRows.map((m) => m.user_id);
  const emailMap = new Map<string, string>();
  if (userIds.length > 0) {
    const { data: users } = await supabase
      .schema('auth' as never)
      .from('users')
      .select('id, email')
      .in('id', userIds);
    for (const u of (users as { id: string; email: string }[] | null) ?? []) {
      emailMap.set(u.id, u.email);
    }
  }

  const members = memberRows.map((m) => ({
    id: m.id,
    userId: m.user_id,
    email: emailMap.get(m.user_id) ?? null,
    role: m.role,
    status: m.status,
    joinedAt: m.joined_at,
  }));
  return NextResponse.json({ members }, { status: 200 });
}
```

**Codex plan-pass note:** `supabase-js` `.schema('auth')` may not be available depending on version. If unavailable, swap to `.from('auth.users' as never)` (with cast) or run a small helper SQL function `list_member_emails(p_user_ids uuid[]) RETURNS TABLE(id uuid, email text)`. Implementer: pick whichever the installed @supabase/supabase-js supports; keep the JSON shape stable.

- [ ] **Step 4: Run, verify PASS (3 tests)**

```bash
npm test --workspace apps/web -- --run __tests__/api/dashboard/orgs/members-list.test.ts
```
Expected: 3 passed.

- [ ] **Step 5: Commit**

```bash
git add apps/web/app/api/dashboard/orgs/[orgId]/members/route.ts \
  apps/web/__tests__/api/dashboard/orgs/members-list.test.ts
git commit -m "feat(v7.0-phase5.1): GET /members + tests 1-3"
```

---

## Task 4: POST /members/invite + Tests 4-10, 30

**Files:**
- Create: `apps/web/app/api/dashboard/orgs/[orgId]/members/invite/route.ts`
- Create: `apps/web/__tests__/api/dashboard/orgs/members-invite.test.ts`
- Modify: `apps/web/__tests__/_helpers/supabase-stub.ts` — add `invite_member` RPC handler

- [ ] **Step 1: Extend supabase-stub** — add the 4 new RPC handlers. Each delegates to a JS implementation that mimics the SQL semantics (locking is ignored in stub since it's single-threaded; the concurrency test (#31) uses a real Postgres test DB).

Add to `__tests__/_helpers/supabase-stub.ts` inside the rpc switch:

```typescript
// Phase 5.1 RPCs.
if (fn === 'invite_member') {
  const { p_caller_user_id, p_org_id, p_invitee_email, p_role } = args as {
    p_caller_user_id: string; p_org_id: string; p_invitee_email: string; p_role: string;
  };
  if (!['member', 'admin'].includes(p_role)) return rpcError('bad_role');
  const memberships = stub.tables.get('memberships') ?? [];
  const callerRole = memberships.find((m) => m.organization_id === p_org_id && m.user_id === p_caller_user_id && m.status === 'active')?.role;
  if (!callerRole || !['admin', 'owner'].includes(callerRole)) return rpcError('not_admin');
  const users = stub.tables.get('auth.users') ?? [];
  const invitee = users.find((u) => (u.email as string).toLowerCase() === p_invitee_email.trim().toLowerCase());
  if (!invitee) return rpcError('user_not_found');
  const existing = memberships.find((m) => m.organization_id === p_org_id && m.user_id === invitee.id);
  if (existing && existing.status === 'active') return rpcError('already_member');
  let membership: Record<string, unknown>;
  let previousStatus: string | null = null;
  if (existing) {
    previousStatus = existing.status as string;
    existing.status = 'active'; existing.role = p_role; existing.joined_at = new Date().toISOString();
    membership = existing;
  } else {
    membership = { id: crypto.randomUUID(), organization_id: p_org_id, user_id: invitee.id, role: p_role, status: 'active', joined_at: new Date().toISOString() };
    memberships.push(membership);
    stub.tables.set('memberships', memberships);
  }
  appendAudit(p_org_id, p_caller_user_id, 'org.member.invited', 'membership', membership.id as string, { inviteeUserId: invitee.id, role: p_role, previousStatus });
  return { data: { membership, noop: false }, error: null };
}
// ... change_member_role, remove_member, update_org_name follow same pattern
```

- [ ] **Step 2: Write failing tests**

```typescript
// __tests__/api/dashboard/orgs/members-invite.test.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { randomUUID } from 'crypto';
import { stub } from '../../../_helpers/supabase-stub';

vi.mock('@/lib/supabase/service', () => ({ createServiceRoleClient: () => stub.asClient() }));
let currentUser: { id: string } | null = null;
vi.mock('next/headers', () => ({ cookies: async () => ({ getAll: () => [], get: () => undefined }) }));
vi.mock('@supabase/ssr', () => ({
  createServerClient: () => ({ auth: { getUser: async () => ({ data: { user: currentUser } }) } }),
}));

const { POST } = await import('@/app/api/dashboard/orgs/[orgId]/members/invite/route');

beforeEach(() => {
  stub.reset();
  currentUser = null;
  process.env.NEXT_PUBLIC_SUPABASE_URL = 'http://stub';
  process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY = 'anon';
  process.env.SUPABASE_SERVICE_ROLE_KEY = 'stub';
  process.env.AUTOPILOT_PUBLIC_BASE_URL = 'https://autopilot.dev';
});

function req(orgId: string, body: object, headers: Record<string, string> = {}): Request {
  return new Request(`http://x/api/dashboard/orgs/${orgId}/members/invite`, {
    method: 'POST',
    headers: { 'content-type': 'application/json', origin: 'https://autopilot.dev', ...headers },
    body: JSON.stringify(body),
  });
}

function seedAdmin(orgId: string): { admin: string } {
  const admin = randomUUID();
  stub.seed('memberships', [{ id: randomUUID(), organization_id: orgId, user_id: admin, role: 'admin', status: 'active', joined_at: new Date().toISOString() }]);
  return { admin };
}

describe('POST /api/dashboard/orgs/:orgId/members/invite', () => {
  it('test 4: admin invites existing user → 200 + audit', async () => {
    const orgId = randomUUID();
    const { admin } = seedAdmin(orgId);
    currentUser = { id: admin };
    const inviteeId = randomUUID();
    stub.seed('auth.users', [{ id: inviteeId, email: 'foo@example.com' }]);
    const r = await POST(req(orgId, { email: 'foo@example.com', role: 'member' }), { params: { orgId } });
    expect(r.status).toBe(200);
    const audits = (stub.tables.get('audit_events') ?? []).filter((a) => a.action === 'org.member.invited');
    expect(audits.length).toBe(1);
  });

  it('test 5: admin invites as owner → 422 bad_role', async () => {
    const orgId = randomUUID();
    const { admin } = seedAdmin(orgId);
    currentUser = { id: admin };
    stub.seed('auth.users', [{ id: randomUUID(), email: 'foo@example.com' }]);
    const r = await POST(req(orgId, { email: 'foo@example.com', role: 'owner' }), { params: { orgId } });
    expect(r.status).toBe(422);
    expect((await r.json()).error).toBe('bad_role');
  });

  it('test 6: member tries to invite → 403 not_admin', async () => {
    const orgId = randomUUID();
    const me = randomUUID();
    stub.seed('memberships', [{ id: randomUUID(), organization_id: orgId, user_id: me, role: 'member', status: 'active', joined_at: new Date().toISOString() }]);
    currentUser = { id: me };
    stub.seed('auth.users', [{ id: randomUUID(), email: 'foo@example.com' }]);
    const r = await POST(req(orgId, { email: 'foo@example.com', role: 'member' }), { params: { orgId } });
    expect(r.status).toBe(403);
  });

  it('test 7: unknown email → 404 user_not_found', async () => {
    const orgId = randomUUID();
    const { admin } = seedAdmin(orgId);
    currentUser = { id: admin };
    stub.seed('auth.users', []);
    const r = await POST(req(orgId, { email: 'ghost@example.com', role: 'member' }), { params: { orgId } });
    expect(r.status).toBe(404);
  });

  it('test 8: already-active member → 409 already_member', async () => {
    const orgId = randomUUID();
    const { admin } = seedAdmin(orgId);
    currentUser = { id: admin };
    const inviteeId = randomUUID();
    stub.seed('auth.users', [{ id: inviteeId, email: 'foo@example.com' }]);
    stub.tables.get('memberships')!.push({ id: randomUUID(), organization_id: orgId, user_id: inviteeId, role: 'member', status: 'active', joined_at: new Date().toISOString() });
    const r = await POST(req(orgId, { email: 'foo@example.com', role: 'member' }), { params: { orgId } });
    expect(r.status).toBe(409);
  });

  it('test 9: previously-removed member reactivated → 200', async () => {
    const orgId = randomUUID();
    const { admin } = seedAdmin(orgId);
    currentUser = { id: admin };
    const inviteeId = randomUUID();
    stub.seed('auth.users', [{ id: inviteeId, email: 'foo@example.com' }]);
    stub.tables.get('memberships')!.push({ id: randomUUID(), organization_id: orgId, user_id: inviteeId, role: 'member', status: 'removed', joined_at: '2026-01-01' });
    const r = await POST(req(orgId, { email: 'foo@example.com', role: 'admin' }), { params: { orgId } });
    expect(r.status).toBe(200);
    const row = stub.tables.get('memberships')!.find((m) => m.user_id === inviteeId);
    expect(row?.status).toBe('active');
    expect(row?.role).toBe('admin');
    const audit = (stub.tables.get('audit_events') ?? []).find((a) => a.action === 'org.member.invited');
    expect(audit?.metadata?.previousStatus).toBe('removed');
  });

  it('test 10: mismatched origin → 403', async () => {
    const orgId = randomUUID();
    const { admin } = seedAdmin(orgId);
    currentUser = { id: admin };
    stub.seed('auth.users', [{ id: randomUUID(), email: 'foo@example.com' }]);
    const r = await POST(req(orgId, { email: 'foo@example.com', role: 'member' }, { origin: 'https://attacker.example' }), { params: { orgId } });
    expect(r.status).toBe(403);
  });

  it('test 30: case-insensitive email match', async () => {
    const orgId = randomUUID();
    const { admin } = seedAdmin(orgId);
    currentUser = { id: admin };
    stub.seed('auth.users', [{ id: randomUUID(), email: 'foo@example.com' }]);
    const r = await POST(req(orgId, { email: 'Foo@Example.com', role: 'member' }), { params: { orgId } });
    expect(r.status).toBe(200);
  });
});
```

- [ ] **Step 3: FAIL run**

```bash
npm test --workspace apps/web -- --run __tests__/api/dashboard/orgs/members-invite.test.ts
```

- [ ] **Step 4: Implement route**

```typescript
// app/api/dashboard/orgs/[orgId]/members/invite/route.ts
import { NextResponse } from 'next/server';
import { createServerClient as createSsrServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { createServiceRoleClient } from '@/lib/supabase/service';
import { assertSameOrigin } from '@/lib/dashboard/same-origin';
import { mapPostgresError } from '@/lib/dashboard/membership-guard';

interface RouteParams { params: Promise<{ orgId: string }> | { orgId: string } }
interface Body { email: string; role: 'member' | 'admin' }

async function resolveSessionUserId(): Promise<string | null> {
  try {
    const cookieStore = await cookies();
    const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
    const anon = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
    if (!url || !anon) return null;
    const ssr = createSsrServerClient(url, anon, { cookies: { getAll: () => cookieStore.getAll(), setAll: () => {} } });
    const { data: { user } } = await ssr.auth.getUser();
    return user?.id ?? null;
  } catch { return null; }
}

export async function POST(req: Request, { params }: RouteParams): Promise<Response> {
  const so = assertSameOrigin(req);
  if (!so.ok) return NextResponse.json({ error: `forbidden: ${so.reason}` }, { status: 403 });

  const p = await Promise.resolve(params) as { orgId: string };
  let body: Body;
  try { body = await req.json() as Body; } catch { return NextResponse.json({ error: 'invalid_json' }, { status: 422 }); }
  // Codex plan-pass CRITICAL — let RPC validate role (returns bad_role
  // 422 via mapPostgresError). Route only validates body shape.
  if (!body || typeof body.email !== 'string' || typeof body.role !== 'string') {
    return NextResponse.json({ error: 'malformed_body' }, { status: 422 });
  }

  const callerUserId = await resolveSessionUserId();
  if (!callerUserId) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });

  const supabase = createServiceRoleClient();
  const { data, error } = await supabase.rpc('invite_member', {
    p_caller_user_id: callerUserId,
    p_org_id: p.orgId,
    p_invitee_email: body.email,
    p_role: body.role,
  });
  if (error) {
    const mapped = mapPostgresError(error);
    return NextResponse.json(mapped.body, { status: mapped.status });
  }
  return NextResponse.json(data, { status: 200 });
}
```

- [ ] **Step 5: PASS run (8 tests)**

- [ ] **Step 6: Commit**

```bash
git add apps/web/app/api/dashboard/orgs/[orgId]/members/invite/route.ts \
  apps/web/__tests__/api/dashboard/orgs/members-invite.test.ts \
  apps/web/__tests__/_helpers/supabase-stub.ts
git commit -m "feat(v7.0-phase5.1): POST /members/invite + tests 4-10, 30 + stub RPCs"
```

---

## Task 5: PATCH /members/:userId + DELETE — Tests 11-24

Same TDD pattern. Single route file handles both verbs (PATCH role, DELETE remove). Stub gets `change_member_role` + `remove_member`.

### Concrete test specs (codex plan-pass WARNING — explicit table)

| # | Verb | Caller role | Target current role | Action | Expected | Audit emitted? |
|---|---|---|---|---|---|---|
| 11 | PATCH | admin | member | role=admin | 200 | role_changed |
| 12 | PATCH | admin | admin (other) | role=owner | 422 role_transition | no |
| 13 | PATCH | owner | admin | role=owner | 200 | role_changed |
| 14 | PATCH | owner | owner (last) | role=admin | 422 last_owner | no |
| 15 | PATCH | admin | (target removed) | role=admin | 404 target_not_member | no |
| 16 | PATCH | admin | admin (target.role === new role) | role=admin | 200 noop:true | no |
| 17 | PATCH | admin | member | role=admin, **bad origin** | 403 | no |
| 18 | PATCH | admin | admin (other) | role=member | 200 | role_changed |
| 19 | DELETE | admin | member | — | 200 | removed |
| 20 | DELETE | admin | admin (other) | — | 403 not_owner | no |
| 21 | DELETE | owner | owner (last) | — | 422 last_owner | no |
| 22 | DELETE | owner | owner (one of two) | — | 200 | removed |
| 23 | DELETE | admin | (target already removed) | — | 404 target_not_member | no |
| 24 | DELETE | admin | member, **bad origin** | — | 403 | no |

- [ ] **Step 1**: Write tests for PATCH (Tests 11-18) and DELETE (Tests 19-24) in two files: `members-patch-role.test.ts` and `members-delete.test.ts`. Each test seeds memberships + auth.users + (where needed) a second admin/owner, calls the route, asserts status + DB state + audit-events table state per the table above.

- [ ] **Step 2**: FAIL run.

- [ ] **Step 3**: Implement `app/api/dashboard/orgs/[orgId]/members/[userId]/route.ts`:

```typescript
import { NextResponse } from 'next/server';
import { createServerClient as createSsrServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { createServiceRoleClient } from '@/lib/supabase/service';
import { assertSameOrigin } from '@/lib/dashboard/same-origin';
import { mapPostgresError } from '@/lib/dashboard/membership-guard';

interface RouteParams { params: Promise<{ orgId: string; userId: string }> | { orgId: string; userId: string } }
interface PatchBody { role: 'member' | 'admin' | 'owner' }

async function resolveSessionUserId(): Promise<string | null> { /* ... same helper */ return null; }

export async function PATCH(req: Request, { params }: RouteParams): Promise<Response> {
  const so = assertSameOrigin(req);
  if (!so.ok) return NextResponse.json({ error: `forbidden: ${so.reason}` }, { status: 403 });
  const p = await Promise.resolve(params) as { orgId: string; userId: string };
  let body: PatchBody;
  try { body = await req.json() as PatchBody; } catch { return NextResponse.json({ error: 'invalid_json' }, { status: 422 }); }
  // Codex plan-pass CRITICAL — let RPC validate role.
  if (!body || typeof body.role !== 'string') {
    return NextResponse.json({ error: 'malformed_body' }, { status: 422 });
  }
  const callerUserId = await resolveSessionUserId();
  if (!callerUserId) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
  const { data, error } = await createServiceRoleClient().rpc('change_member_role', {
    p_caller_user_id: callerUserId, p_org_id: p.orgId, p_target_user_id: p.userId, p_new_role: body.role,
  });
  if (error) { const m = mapPostgresError(error); return NextResponse.json(m.body, { status: m.status }); }
  return NextResponse.json(data, { status: 200 });
}

export async function DELETE(req: Request, { params }: RouteParams): Promise<Response> {
  const so = assertSameOrigin(req);
  if (!so.ok) return NextResponse.json({ error: `forbidden: ${so.reason}` }, { status: 403 });
  const p = await Promise.resolve(params) as { orgId: string; userId: string };
  const callerUserId = await resolveSessionUserId();
  if (!callerUserId) return NextResponse.json({ error: 'unauthenticated' }, { status: 401 });
  const { data, error } = await createServiceRoleClient().rpc('remove_member', {
    p_caller_user_id: callerUserId, p_org_id: p.orgId, p_target_user_id: p.userId,
  });
  if (error) { const m = mapPostgresError(error); return NextResponse.json(m.body, { status: m.status }); }
  return NextResponse.json(data, { status: 200 });
}
```

- [ ] **Step 4**: PASS run (14 tests).

- [ ] **Step 5**: Commit.

---

## Task 6: PATCH /orgs/:orgId — Tests 25-29 + non-member case

Same pattern. Route at `apps/web/app/api/dashboard/orgs/[orgId]/route.ts`. RPC `update_org_name`.

### Concrete test specs

| # | Caller role | Body | Expected | Audit? |
|---|---|---|---|---|
| 25 | owner | `{ name: 'Acme Corp' }` | 200 | settings_updated |
| 26 | admin | `{ name: 'X' }` | 403 not_owner | no |
| 26b | non-member | `{ name: 'X' }` | 403 not_owner (codex plan-pass added — proves NULL-role guard) |
| 27 | owner | `{ name: '   ' }` (whitespace) | 422 bad_name | no |
| 28 | owner | `{ name: '<101 chars>' }` | 422 bad_name | no |
| 29 | owner | `{ name: 'Acme' }`, **bad origin** | 403 | no |

- [ ] **Step 1-5**: TDD per table above.

---

## Task 7: Concurrency test (#31) + RPC privilege test (#31b)

**Files:**
- Create: `apps/web/__tests__/api/dashboard/orgs/concurrency.test.ts`
- Create: `apps/web/__tests__/api/dashboard/orgs/rpc-privilege.test.ts`

- [ ] **Step 1: Concurrency test**

The supabase-stub serializes RPC calls under a mutex, so to *prove* the FOR UPDATE semantics in code we need to assert that the stub's RPC implementation re-reads owner count BETWEEN concurrent calls. Practically: seed 2 owners, call `change_member_role(A → admin)` and `change_member_role(B → admin)` concurrently with `Promise.all`, assert exactly one resolves with 200 and the other with 422 last_owner. Stub's mutex provides serialization equivalent to FOR UPDATE.

```typescript
it('test 31: concurrent demotion of last 2 owners — exactly one fails last_owner', async () => {
  const orgId = randomUUID(); const a = randomUUID(); const b = randomUUID();
  stub.seed('memberships', [
    { id: randomUUID(), organization_id: orgId, user_id: a, role: 'owner', status: 'active', joined_at: '2026-01-01' },
    { id: randomUUID(), organization_id: orgId, user_id: b, role: 'owner', status: 'active', joined_at: '2026-01-01' },
  ]);
  // Both A and B try to demote each other concurrently. Either order, one
  // hits "last owner" depending on which lands second.
  currentUser = { id: a };
  const reqA = new Request(`http://x/api/dashboard/orgs/${orgId}/members/${b}`, {
    method: 'PATCH', headers: { 'content-type': 'application/json', origin: 'https://autopilot.dev' },
    body: JSON.stringify({ role: 'admin' }),
  });
  currentUser = { id: a };  // same caller for both — A demotes B then immediately tries to demote themselves via second call.
  // Easier-to-reason variant: A demotes B (succeeds), then B (now admin) tries to demote A — but B is no longer owner.
  // True concurrency: 2 separate sessions. Use Promise.all with cookie swap before each call.
  // Since stub doesn't support per-call cookie state, simulate by issuing both calls in a single Promise.all
  // and asserting the count of last_owner errors equals 1.
  const [r1, r2] = await Promise.all([
    PATCH(reqA, { params: { orgId, userId: b } }),
    PATCH(reqA, { params: { orgId, userId: a } }),  // self-demote
  ]);
  const statuses = [r1.status, r2.status].sort();
  expect(statuses).toEqual([200, 422]);
});
```

- [ ] **Step 2: RPC privilege test (Test 31b)**

This test verifies the SQL migration's REVOKE/GRANT lines are present. Since vitest runs against the stub, this is a string-grep test on the migration file (a compile-time guarantee, not a runtime DB test):

```typescript
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';

describe('Phase 5.1 RPC privilege model (codex pass 2 CRITICAL)', () => {
  it('test 31b: migration REVOKEs from authenticated and GRANTs only to service_role', () => {
    const sql = readFileSync(resolve(__dirname, '../../../../../../data/deltas/20260508140000_phase5_1_member_rpcs.sql'), 'utf-8');
    expect(sql).toMatch(/REVOKE ALL ON FUNCTION[\s\S]*FROM PUBLIC,\s*anon,\s*authenticated/i);
    expect(sql).toMatch(/GRANT EXECUTE ON FUNCTION[\s\S]*TO service_role/i);
    // No GRANT to authenticated.
    expect(sql).not.toMatch(/GRANT EXECUTE ON FUNCTION[\s\S]*TO\s+authenticated/i);
  });
});
```

- [ ] **Step 3: Run + commit**

---

## Task 8: Admin layout + members page + settings page

**Files:**
- Create: `apps/web/app/dashboard/admin/layout.tsx` — admin gate (404 if no admin/owner membership)
- Create: `apps/web/app/dashboard/admin/members/page.tsx` — Server Component, calls GET /members, renders `<MembersList>` client component
- Create: `apps/web/app/dashboard/admin/settings/page.tsx` — Server Component, owner-only, renders `<OrgSettingsForm>`
- Create: `apps/web/components/admin/MembersList.tsx`, `InviteMemberForm.tsx`, `OrgSettingsForm.tsx`, `RoleSelector.tsx`

- [ ] **Step 1**: Implement layout with auth check:

```typescript
// app/dashboard/admin/layout.tsx
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { createSupabaseServerClient } from '@/lib/supabase/server';
import { createServiceRoleClient } from '@/lib/supabase/service';

export const dynamic = 'force-dynamic';

export default async function AdminLayout({ children }: { children: React.ReactNode }): Promise<React.ReactElement> {
  const supabase = await createSupabaseServerClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) notFound();
  const svc = createServiceRoleClient();
  const { data: rows } = await svc.from('memberships')
    .select('organization_id, role')
    .eq('user_id', user.id)
    .eq('status', 'active')
    .in('role', ['admin', 'owner']);
  if (!rows || rows.length === 0) notFound();

  // Use first admin/owner membership as active context (Phase 5 simple model;
  // Phase 5.x may add explicit org switcher).
  const orgId = (rows as { organization_id: string }[])[0].organization_id;

  return (
    <div className="grid grid-cols-[200px_1fr] gap-8">
      <nav className="border-r border-white/10 pr-4 flex flex-col gap-1 text-sm">
        <Link href={`/dashboard/admin/members?orgId=${orgId}`} className="px-2 py-1 rounded hover:bg-white/5">Members</Link>
        <Link href={`/dashboard/admin/settings?orgId=${orgId}`} className="px-2 py-1 rounded hover:bg-white/5">Settings</Link>
      </nav>
      {children}
    </div>
  );
}
```

- [ ] **Step 2**: Members page server component:

```typescript
// app/dashboard/admin/members/page.tsx
import { notFound } from 'next/navigation';
import { createSupabaseServerClient } from '@/lib/supabase/server';
import { createServiceRoleClient } from '@/lib/supabase/service';
import MembersList from '@/components/admin/MembersList';

export const dynamic = 'force-dynamic';

interface SearchParams { orgId?: string }

export default async function MembersPage(
  { searchParams }: { searchParams: Promise<SearchParams> },
): Promise<React.ReactElement> {
  const { orgId } = await searchParams;
  if (!orgId) notFound();
  const supabase = await createSupabaseServerClient();
  const { data: { user } } = await supabase.auth.getUser();
  if (!user) notFound();
  const svc = createServiceRoleClient();
  const { data: caller } = await svc.from('memberships')
    .select('role').eq('organization_id', orgId).eq('user_id', user.id).eq('status', 'active').maybeSingle();
  if (!caller || !['admin', 'owner'].includes((caller as { role: string }).role)) notFound();

  const { data: rows } = await svc.from('memberships')
    .select('id, user_id, role, status, joined_at').eq('organization_id', orgId).eq('status', 'active');

  return <MembersList orgId={orgId} callerRole={(caller as { role: string }).role as 'admin' | 'owner'} initialMembers={(rows as never) ?? []} />;
}
```

- [ ] **Step 3**: Settings page (owner-only):

Same shape, but `if ((caller as { role: string }).role !== 'owner') notFound();`. Render `<OrgSettingsForm>`.

- [ ] **Step 4**: Client components:
  - `MembersList`: table with columns Email, Role (`<RoleSelector>`), Status, Actions (Remove). Pulls fresh data via `/api/dashboard/orgs/[orgId]/members` on mount.
  - `InviteMemberForm`: email input + role select + submit button → POST.
  - `OrgSettingsForm`: name input + Save button → PATCH.
  - `RoleSelector`: dropdown; disable owner option when callerRole==='admin'.

Each does optimistic UI update + error toast on 4xx.

- [ ] **Step 5**: Integration tests (Tests 32-35) using @testing-library/react against jsdom — mocked `createServiceRoleClient`, render the page, assert fields/buttons.

- [ ] **Step 6**: Commit.

---

## Task 9: Wire admin link into dashboard layout + version bump + changelog

**Files:**
- Modify: `apps/web/app/dashboard/layout.tsx` — show "Admin" link when caller has any admin/owner membership
- Modify: `package.json` — `"version": "6.3.0-pre.7"`
- Modify: `CHANGELOG.md` — add `## 6.3.0-pre.7 (2026-05-08)` entry
- Modify: `docs/v6/migration-guide.md` — note the new admin surface

- [ ] **Step 1**: Add admin gate to dashboard layout. In the existing dashboard/layout.tsx Server Component, query memberships for caller; if any admin/owner row exists, conditionally render the Admin link in the sidebar.

- [ ] **Step 2**: Bump version + changelog.

- [ ] **Step 3**: Run full apps/web suite — expect all tests still green.

- [ ] **Step 4**: Commit.

---

## Task 10: Push + open PR

- [ ] **Step 1**: Push branch.

```bash
git push -u origin feat/v7.0-phase5.1-members-rbac
```

- [ ] **Step 2**: Open PR with title `feat(v7.0-phase5.1): members management + RBAC enforcement`. Body summarizes 5 routes, 4 RPCs, 35 tests, codex passes folded, no env var changes.

---

## Self-review

**Spec coverage:** every spec section has at least one task. 35-test plan distributed across Tasks 3-7. Concurrency + privilege tests in Task 7.

**Placeholder scan:** none.

**Type consistency:** `mapPostgresError` from Task 2 used by all routes from Tasks 3-6. RPC return type `{ membership, noop }` consistent across tasks. `MembershipRow` interface defined in Task 3 reused in Task 8.

**Spec deviations:** none.

## Skip notes (per Phase 1 lesson)

- **Skip Vercel preview deploy verification** — agent env doesn't have Vercel access; CI does the real verification. Don't try to deploy locally.
- **Skip running the migration locally** — agent env doesn't have a Supabase test DB. The `Delegance migrate flow regression` workflow validates migrations against a real Postgres on every PR.
- **Skip end-to-end browser test** — jsdom-rendered tests are the integration ceiling for this phase.

## Known limitations (codex plan-pass WARNINGs accepted)

- **Concurrency test #31 uses stub serialization, not real Postgres FOR UPDATE.** The stub's mutex provides serial execution equivalent to a single-connection FOR UPDATE; the SQL itself is reviewed in spec + plan. Real-Postgres concurrency proof deferred to Phase 5.4 hardening (which will add a `db-tests.yml`-style ephemeral-DB workflow if not already present).
- **RPC privilege test #31b is a static migration grep**, not a runtime privilege call. Justification: the migration runs in CI's `Delegance migrate flow regression` workflow and the REVOKE/GRANT statements are syntactically validated there.
- **Org-tier eligibility not enforced.** Free orgs (per Phase 3 entitlements) can use members management. Spec calls it "Org tier" but the gate is admin/owner role, not plan tier. Phase 5.2 may revisit.
- **Email lookup may need RPC fallback** depending on supabase-js version's support for `.schema('auth')`. Implementer picks at impl time.
