Warden plan

18 - admin control plane IA: People & Access / Content / Organization (issue #214)

← eval suite index


tier: needs-server requires: [needs-postgres]


18 - admin control plane IA: People & Access / Content / Organization (issue #214)

What it proves

The org-admin control plane (/admin) is organized into exactly the three groups issue #214 defines, with the moves/renames/dissolutions it calls for actually shipped — not just described:

This is a structure/IA proof, not a visual-design proof (the issue is explicit: "Structure only — not visual design").

Prerequisites

Step 1: static — the three groups, and only those, on the admin index

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"

IDX="app/src/app/(shell)/admin/page.tsx"

# IA-1: exactly three group labels, matching #214's vocabulary.
for label in "People & access" "Content" "Organization"; do
  grep -qi "$label" "$IDX" \
    && warden_pass "admin index declares the '$label' group" \
    || warden_fail "admin index is missing the '$label' group — #214's three-group IA is not in place"
done

# IA-2: no standalone Guardians row under /admin (org-admin index).
if grep -qi '"Guardians"' "$IDX"; then
  warden_fail "admin index still lists a standalone Guardians row — guardian appointment belongs to the workspace-admin view per #214, not org-admin"
else
  warden_pass "no standalone Guardians row on the admin index"
fi

# IA-3: Spend does not appear in the live group definitions (commented out or removed, not just hidden by CSS).
if grep -E '^\s*\{' "$IDX" | grep -qi "spend"; then
  warden_fail "a live 'Spend' row exists in the admin index rows array — #214 hides Spend from the nav"
else
  warden_pass "Spend is absent from the live admin index rows"
fi

Step 2: static — Content group rename and Organization group reshape

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"

IDX="app/src/app/(shell)/admin/page.tsx"

# IA-4: Content style renamed to House style, everywhere it's user-facing.
if grep -qi "house style" "$IDX"; then
  warden_pass "admin index shows 'House style', not 'Content style'"
else
  warden_fail "admin index still shows the old 'Content style' label — #214 renames it to 'House style'"
fi

CONTENT_PAGE=$(grep -rli "house style" app/src/app 2>/dev/null | head -1)
if [ -n "$CONTENT_PAGE" ]; then
  warden_pass "some admin page renders the 'House style' label ($CONTENT_PAGE)"
else
  warden_fail "no admin page renders a 'House style' label — the rename looks index-only, not applied to the page itself"
fi

# IA-5: Providers is no longer its own admin-index destination — it folded into Settings as Models.
if grep -qi '"Providers' "$IDX"; then
  warden_fail "admin index still lists a standalone 'Providers' destination — #214 folds it into Settings as Models"
else
  warden_pass "no standalone Providers row on the admin index"
fi
if grep -rqi "models" "app/src/app/(shell)/admin/settings/page.tsx"; then
  warden_pass "the Settings page itself references Models"
else
  warden_fail "the Settings page has no Models section — Providers→Models move is not wired into Settings"
fi

# IA-6: a Connections destination exists in the Organization group, describing the org MCP endpoint/keypair (not just the personal Settings > Connections page).
if grep -qi "connections" "$IDX"; then
  warden_pass "admin index lists a Connections destination"
else
  warden_fail "admin index has no Connections destination — MCP endpoint/keypair have no Organization-group home"
fi

# IA-7: a Data & danger destination/section exists, and export-all is reachable from it.
if grep -qi "data.*danger\|danger.*data" "$IDX"; then
  warden_pass "admin index (or a linked page) names a 'Data & danger' surface"
else
  warden_fail "no 'Data & danger' surface found on the admin index — export/delete have no fenced home"
fi

Step 3: live — the ability gate and existing mutations still hold (regression floor)

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"

JAR="$(mktemp /tmp/warden-admin214-cookies-XXXXXX.txt)"
curl -s -o /dev/null -c "$JAR" -X POST -H 'Content-Type: application/json' -H "Origin: $APP_BASE" \
  -d '{"email":"andrew@robin.ai","password":"robin2026"}' \
  "$APP_BASE/api/auth/sign-in/email"

# IA-8: an unauthenticated caller is still refused the members list.
UNAUTH_CODE=$(curl -s -o /dev/null -w '%{http_code}' "$APP_BASE/api/org/members")
case "$UNAUTH_CODE" in
  401|403) warden_pass "unauthenticated GET /api/org/members is refused ($UNAUTH_CODE)" ;;
  *) warden_fail "unauthenticated GET /api/org/members returned $UNAUTH_CODE — org-member data may be exposed without auth" ;;
esac

# IA-9: the seeded org-admin can still list org members (existing surface, unregressed by the IA move).
MEMBERS_CODE=$(curl -s -b "$JAR" -H "Origin: $APP_BASE" -o /tmp/warden-admin214-members.json -w '%{http_code}' "$APP_BASE/api/org/members")
[ "$MEMBERS_CODE" = "200" ] \
  && warden_pass "authenticated org-admin GET /api/org/members returns 200" \
  || warden_fail "authenticated org-admin GET /api/org/members returned $MEMBERS_CODE, expected 200"

MEMBER_COUNT=$(jq -r '.members | length' /tmp/warden-admin214-members.json 2>/dev/null)
[ -n "$MEMBER_COUNT" ] && [ "$MEMBER_COUNT" != "0" ] && [ "$MEMBER_COUNT" != "null" ] \
  && warden_pass "at least one member returned ($MEMBER_COUNT) — enough fixture data to open a member view" \
  || warden_fail "org-members response has no usable members — cannot exercise the member-page checks in step 4"

Step 4: rendered browser — one member page shows organization + workspaces + guardian-of

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"

if ! npx agent-browser session >/dev/null 2>&1; then
  warden_skip "rendered member-page checks" "npx agent-browser is unavailable on this box"
else
  npx agent-browser cookies clear >/dev/null 2>&1
  npx agent-browser open "$APP_BASE/login" >/dev/null
  npx agent-browser wait "input" >/dev/null
  npx agent-browser fill "input[type='email'], input[name='email']" "andrew@robin.ai" >/dev/null
  npx agent-browser fill "input[type='password'], input[name='password']" "robin2026" >/dev/null
  npx agent-browser click "button[type='submit']" >/dev/null
  sleep 2

  npx agent-browser open "$APP_BASE/admin/members" >/dev/null
  npx agent-browser wait "text=Members" >/dev/null 2>&1
  npx agent-browser click "table tr, [data-member-row], .mrow" >/dev/null 2>&1
  sleep 1

  # IA-10: opening a member surfaces an Organization block (role, MCP access,
  # lifecycle) on the same screen — the "member page" the issue describes,
  # whether that's a route or a same-page detail panel.
  ORG_BLOCK=$(npx agent-browser get text "text=/Org role|MCP access/i" 2>/dev/null)
  if [ -n "$ORG_BLOCK" ]; then
    warden_pass "an Organization block (org role / MCP access) is visible from the member view"
  else
    warden_skip "member-page Organization block check" "selector drift or no member row was opened — re-verify manually before failing this"
  fi

  # IA-11: a Workspaces block lists the person's workspace memberships + roles.
  WS_BLOCK=$(npx agent-browser get text "text=/Workspaces? \(/i" 2>/dev/null)
  if [ -n "$WS_BLOCK" ]; then
    warden_pass "a Workspaces block (with a count) is visible from the member view: $WS_BLOCK"
  else
    warden_fail "no 'Workspaces (N)' block found on the member view — the issue calls for editable per-person workspace access here, not only on the Workspaces page"
  fi

  # IA-12: a Guardian-of block is present and is presented as read-only context
  # (no role selector / no remove control next to it), distinct from the
  # editable Workspaces block above.
  GUARDIAN_BLOCK=$(npx agent-browser get text "text=/Guardian of/i" 2>/dev/null)
  if [ -n "$GUARDIAN_BLOCK" ]; then
    warden_pass "a 'Guardian of' block is visible from the member view"
  else
    warden_fail "no 'Guardian of' block found on the member view — guardianships must show in-context per #214, not be omitted entirely"
  fi
fi

Step 5: negative — org-wide delete-all stays absent unless a real endpoint backs it

set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"

# IA-13: no client-side call to a destructive, unscoped "delete everything"
# route exists unless a matching org-scoped server route does. This guards
# against the exact defect #214's own history describes: a delete button
# wired to nothing (or worse, to an unscoped table truncation).
DANGER_HITS=$(grep -rli "delete all data\|delete everything\|DELETE /users/data\|DELETE /org/data" app/src 2>/dev/null)
if [ -z "$DANGER_HITS" ]; then
  warden_pass "no client code references a delete-all-data control — Data & danger's destructive action is not shipped ahead of its endpoint"
else
  # If it exists client-side, a real org-scoped server route must exist too.
  if grep -rqi "delete.*org.*data\|org.*delete.*data" server/src/modules 2>/dev/null; then
    warden_pass "a delete-all UI reference exists AND a matching org-scoped server route exists"
  else
    warden_fail "client code references a delete-all-data control but no org-scoped server route backs it: $DANGER_HITS"
  fi
fi

# IA-14: whatever export-all-data control exists still requires org authority
# server-side (unauthenticated export must be refused).
UNAUTH_EXPORT=$(curl -s -o /dev/null -w '%{http_code}' -X POST "${SERVER_URL:-http://localhost:3000}/users/export?format=zip")
case "$UNAUTH_EXPORT" in
  401|403) warden_pass "unauthenticated data-export request is refused ($UNAUTH_EXPORT)" ;;
  *) warden_fail "unauthenticated data-export request returned $UNAUTH_EXPORT — expected 401/403" ;;
esac

Shape (note for the next author)

Step 1/2's greps are loose source checks — they read admin/page.tsx's row labels, not rendered pixels, so a page that renames a label without moving the underlying control will still pass here; step 3's live calls and step 4's rendered pass are the load-bearing proof that the surfaces actually work end to end. IA-10/IA-11/IA-12 deliberately don't assert a specific route shape (/admin/members/:id vs. a same-page detail panel) — the issue asks for "a member page," not a URL scheme, and the codebase already renders per-member detail as a same-page panel (see admin/members/page.tsx), which satisfies the intent if the missing Workspaces/Guardian-of blocks land on it.

Stays manual / not asserted here:

Batch selector

Filename carries the v11-batch prefix segment so bash .warden/run.sh v11-batch (a filename-prefix match per run.sh's non-phase:/tier: argument resolution) selects this plan alongside any siblings from the same design sweep, without adding an eighth value to the closed tier: vocabulary in .warden/TIERS.md. tier: needs-server / requires: [needs-postgres] is this plan's real, checker-honored declaration.