Warden plan
tier: needs-server requires: [needs-postgres]
The enterprise app shell holds against the LIVE stack (app :8080 → server :3000): a browser-shaped sign-in through the app origin round-trips to a non-null session (the blanked-body regression that bit twice); every shell surface answers 200 for that authed session and the legacy /graph URL permanently redirects into /socrates/graph; the design-system substrate is intact in the tree (tokens declared, screens.css wired exactly once, the evicted @base-ui/toast/design-bundle layers stay evicted, the dual-rail AppSidebar replaced the legacy Header/Sidebar chrome); and the dual-rail shell actually renders in a real browser after logging in through the real form. Durable, not sprint-scoped — a refactor that breaks any of these seams reds this plan.
$APP_URL (default http://localhost:8080) proxying /api/auth/* to the Hono server on $SERVER_URL (default http://localhost:3000). The server's WIKI_ORIGIN must include the app origin — that is part of what step 1 gates.WARDEN_USERS_shell_email / WARDEN_USERS_shell_password, defaults in warden.config.sh) on the product's invitation rails — one SQL-seeded pending invitation, then real sign-up / sign-in / accept-invitation HTTP (single-user mode forbids plain sign-ups once any user exists). This survives suite order: plan 06 resets the shared robin_ci database, and this plan simply re-provisions on the next run.curl, jq, grep, psql on PATH; npx agent-browser (0.26.x) for step 4. The env file (DATABASE_URL etc., same contract as plans 05/06) is provisioned automatically by .warden/run.sh from the tracked .warden/env/ci-env.template.sh plus the machine-local secrets file ~/.config/robin/warden-secrets.sh (mode 600, outside every git repository; override with WARDEN_SECRETS_FILE); first-time setup: mkdir -p ~/.config/robin && cp .warden/env/secrets.local.example.sh ~/.config/robin/warden-secrets.sh && chmod 600 ~/.config/robin/warden-secrets.sh.invitation row (only when the warden user lacks membership), the sign-up/accept (first run after a DB reset), the sign-in's session rows, and the guarded PATCH /users/onboard (WHERE onboarded_at IS NULL — a no-op once set). Product data untouched.set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/db.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# shellcheck disable=SC1091
source "${WARDEN_ENV_FILE:?WARDEN_ENV_FILE not set — run this plan via .warden/run.sh}" # DATABASE_URL for the provisioning checks
APP_BASE="${APP_URL:-http://localhost:8080}"
SHELL_EMAIL="${WARDEN_USERS_shell_email:-warden-shell@robin.test}"
SHELL_PASSWORD="${WARDEN_USERS_shell_password:-warden-shell-2026}"
# ── Self-provision the warden identity on the product's own rails ──
# Sign-ups are FORBIDDEN once any user exists (single-user mode) UNLESS a
# pending invitation exists for the email — and sign-in for a member-less
# user is likewise only allowed with a pending invitation. So: when the
# warden user lacks a member row (fresh DB after 06's reset, or first ever
# run), seed ONE pending org invitation via SQL (the only out-of-band write;
# everything downstream — sign-up, sign-in, accept — is real product HTTP).
SHELL_USER=$(warden_psql_count users "email = '$SHELL_EMAIL'")
SHELL_MEMBER=$(warden_psql_one "SELECT count(*) FROM member m JOIN users u ON u.id = m.user_id WHERE u.email = '$SHELL_EMAIL'")
USERS_TOTAL=$(warden_psql_one "SELECT count(*) FROM users")
if [ "${SHELL_MEMBER:-0}" = "0" ] && [ "${USERS_TOTAL:-0}" != "0" ]; then
warden_psql_exec "INSERT INTO invitation (id, organization_id, email, role, status, inviter_id, expires_at, created_at)
SELECT 'warden-shell-' || floor(extract(epoch from now()))::bigint, o.id, '$SHELL_EMAIL', 'super_admin', 'pending', m.user_id, now() + interval '1 day', now()
FROM organization o JOIN member m ON m.organization_id = o.id LIMIT 1"
fi
# Sign-up through the app origin — only when the user is absent (single-user
# mode 403s the duplicate BEFORE better-auth's own USER_ALREADY_EXISTS check,
# so a re-run must not re-attempt it).
if [ "${SHELL_USER:-0}" = "0" ]; then
SIGNUP_BODY=/tmp/warden-appshell-signup.json
SIGNUP_CODE=$(curl -s -o "$SIGNUP_BODY" -w '%{http_code}' -X POST \
-H 'Content-Type: application/json' -H "Origin: $APP_BASE" \
-d "$(jq -cn --arg e "$SHELL_EMAIL" --arg p "$SHELL_PASSWORD" '{name:"Warden Shell",email:$e,password:$p}')" \
"$APP_BASE/api/auth/sign-up/email")
[ "$SIGNUP_CODE" = "200" ] \
&& warden_pass "warden identity is provisioned (invited sign-up returned 200)" \
|| warden_fail "warden identity provisioning failed — sign-up returned $SIGNUP_CODE: $(head -c 160 "$SIGNUP_BODY")"
else
warden_pass "warden identity is provisioned (user already present from an earlier run)"
fi
# Sign in THROUGH THE APP ORIGIN — the Next.js rewrite proxies /api/auth/* to
# the server, and `Origin: $APP_BASE` is what a real browser sends from the
# login form. better-auth's trustedOrigins must accept it: this exact seam
# 403s (INVALID_ORIGIN) whenever the server boots with a WIKI_ORIGIN that
# omits the app origin, which silently bricks every browser login.
JAR="$(mktemp /tmp/warden-appshell-cookies-XXXXXX.txt)"
SIGNIN_BODY=/tmp/warden-appshell-signin.json
SIGNIN_CODE=$(curl -s -o "$SIGNIN_BODY" -w '%{http_code}' -c "$JAR" -X POST \
-H 'Content-Type: application/json' -H "Origin: $APP_BASE" \
-d "$(jq -cn --arg e "$SHELL_EMAIL" --arg p "$SHELL_PASSWORD" '{email:$e,password:$p}')" \
"$APP_BASE/api/auth/sign-in/email")
if [ "$SIGNIN_CODE" = "200" ]; then
warden_pass "sign-in via the app origin returns 200 (trustedOrigins accepts the app origin)"
else
warden_fail "sign-in via the app origin returned $SIGNIN_CODE — $(head -c 160 "$SIGNIN_BODY")"
fi
# The blanked-body regression gate: get-session with the captured cookies must
# be 200 AND carry the signed-in user. A 200 whose body is null/blank is
# exactly the after-hook regression (fix 78d7bef) this assertion pins.
SESSION_BODY=/tmp/warden-appshell-session.json
SESSION_CODE=$(curl -s -o "$SESSION_BODY" -w '%{http_code}' -b "$JAR" \
"$APP_BASE/api/auth/get-session")
[ "$SESSION_CODE" = "200" ] \
&& warden_pass "get-session with the captured cookies returns 200" \
|| warden_fail "get-session returned $SESSION_CODE"
if grep -q "$SHELL_EMAIL" "$SESSION_BODY"; then
warden_pass "get-session body is a NON-NULL session carrying $SHELL_EMAIL (blanked-body gate)"
else
warden_fail "get-session body does not carry the user — blanked/null session body: $(head -c 160 "$SESSION_BODY")"
fi
# Accept the pending invitation (when one was seeded above) so the member
# row exists — onAfterSignup deliberately skips org creation for invited
# emails, and AuthGuard's org-membership gate blanks the shell for
# member-less users. A no-op when the user already holds a member row.
INVITE_ID=$(warden_psql_one "SELECT id FROM invitation WHERE email = '$SHELL_EMAIL' AND status = 'pending' AND expires_at > now() ORDER BY created_at DESC LIMIT 1")
if [ -n "$INVITE_ID" ]; then
curl -s -o /dev/null -b "$JAR" -X POST \
-H 'Content-Type: application/json' -H "Origin: $APP_BASE" \
-d "$(jq -cn --arg i "$INVITE_ID" '{invitationId:$i}')" \
"$APP_BASE/api/auth/organization/accept-invitation"
fi
[ "$(warden_psql_one "SELECT count(*) FROM member m JOIN users u ON u.id = m.user_id WHERE u.email = '$SHELL_EMAIL'")" != "0" ] \
&& warden_pass "warden identity holds a member row (org membership gate satisfied)" \
|| warden_fail "warden identity has NO member row — AuthGuard's org-membership gate will blank the shell"
# Complete onboarding so the shell renders instead of the onboarding wizard
# (AuthGuard bounces un-onboarded users to `/`). The route is idempotent by
# construction (`SET onboarded_at WHERE onboarded_at IS NULL`).
ONBOARD_CODE=$(curl -s -o /dev/null -w '%{http_code}' -b "$JAR" -X PATCH \
-H "Origin: $APP_BASE" "$APP_BASE/api/users/onboard")
[ "$ONBOARD_CODE" = "200" ] \
&& warden_pass "warden identity onboarding is complete (idempotent PATCH /users/onboard)" \
|| warden_fail "PATCH /users/onboard returned $ONBOARD_CODE — the shell would bounce to the onboarding wizard"
# Hand the session to the rest of the plan via the lib's cookie strategy.
export WARDEN_AUTH_STRATEGY=cookie-session
export WARDEN_AUTH_COOKIE_JAR="$JAR"
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
source "$WARDEN_LIB/api.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
# 200 after redirects, with the step-1 cookies. The auth guard is client-side
# today (an anonymous GET also 200s); the cookies keep this gate honest if the
# guard ever moves server-side, and a 404/500 here means an IA surface fell
# out of the shell.
for route in /wiki /explorer /inbox /socrates /socrates/graph /search \
/profile /settings/api-keys /admin /admin/members \
/admin/guardians /wiki-management; do
CODE=$(warden_authed_curl -L -o /dev/null -w '%{http_code}' "$APP_BASE$route")
[ "$CODE" = "200" ] \
&& warden_pass "authed GET $route answers 200" \
|| warden_fail "authed GET $route answered $CODE"
done
# The legacy /graph URL must PERMANENTLY redirect into the socrates graph
# (next.config.ts redirects, permanent:true → 308; 301 also acceptable).
REDIR=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' "$APP_BASE/graph")
case "$REDIR" in
"308 $APP_BASE/socrates/graph"|"301 $APP_BASE/socrates/graph")
warden_pass "/graph permanently redirects (${REDIR%% *}) to /socrates/graph" ;;
*)
warden_fail "/graph redirect wrong — got '$REDIR', expected 308/301 → $APP_BASE/socrates/graph" ;;
esac
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
# 3a. @base-ui stays fully evicted — code AND dependency manifest.
if grep -rn '@base-ui' app/src app/package.json >/dev/null 2>&1; then
warden_fail "@base-ui references crept back into app/src or app/package.json: $(grep -rln '@base-ui' app/src app/package.json | head -3 | tr '\n' ' ')"
else
warden_pass "zero @base-ui references in app/src + app/package.json"
fi
# 3b. the legacy toast layer has no importers (dead surface stays dead).
if grep -rn 'ui/toast' app/src >/dev/null 2>&1; then
warden_fail "ui/toast importers reappeared: $(grep -rln 'ui/toast' app/src | head -3 | tr '\n' ' ')"
else
warden_pass "zero ui/toast importers in app/src"
fi
# 3c. the shell tokens are declared in the token source of truth.
GLOBALS=app/src/app/globals.css
grep -q -- '--ink:' "$GLOBALS" \
&& warden_pass "--ink token is declared in globals.css" \
|| warden_fail "--ink token missing from globals.css"
grep -q -- '--rail-bg:' "$GLOBALS" \
&& warden_pass "--rail-bg token is declared in globals.css" \
|| warden_fail "--rail-bg token missing from globals.css"
# 3d. screens.css is wired exactly once (the globals.css @import). A second
# import duplicates the class layer; zero means the screen surfaces lost
# their styles. Prose mentions in comments don't count — only import syntax.
SCREENS_IMPORTS=$(grep -rhE "@import ['\"][^'\"]*screens\.css|^import[^;]*screens\.css" \
app/src --include='*.css' --include='*.ts' --include='*.tsx' | wc -l | tr -d ' ')
[ "$SCREENS_IMPORTS" = "1" ] \
&& warden_pass "screens.css is imported exactly once" \
|| warden_fail "screens.css import count is $SCREENS_IMPORTS, expected exactly 1"
# 3e. nothing imports from the design bundle staging dir — the port is total.
if grep -rE "from ['\"][^'\"]*design/" app/src >/dev/null 2>&1; then
warden_fail "design/ bundle imports leaked into app/src: $(grep -rlE "from ['\"][^'\"]*design/" app/src | head -3 | tr '\n' ' ')"
else
warden_pass "zero imports of the design/ bundle under app/src"
fi
# 3f. the dual-rail AppSidebar owns the chrome; legacy Header/Sidebar are gone.
[ -f app/src/components/layout/AppSidebar.tsx ] \
&& warden_pass "AppSidebar.tsx exists (the dual-rail shell owner)" \
|| warden_fail "app/src/components/layout/AppSidebar.tsx is missing"
LEGACY=$(find app/src \( -name Header.tsx -o -name Sidebar.tsx \) | tr '\n' ' ')
[ -z "$LEGACY" ] \
&& warden_pass "legacy Header.tsx / Sidebar.tsx chrome is gone from app/src" \
|| warden_fail "legacy chrome files still present: $LEGACY"
set -uo pipefail
source "$WARDEN_LIB/assert.sh"
cd "${PROJECT_ROOT:-$(git rev-parse --show-toplevel)}"
APP_BASE="${APP_URL:-http://localhost:8080}"
SHELL_EMAIL="${WARDEN_USERS_shell_email:-warden-shell@robin.test}"
SHELL_PASSWORD="${WARDEN_USERS_shell_password:-warden-shell-2026}"
# agent-browser 0.26.x drift (same finding as 06-socrates): bare `count` is not
# a command (selector counts live under `get count`), and `wait` only parses
# the selector-first form — `wait --timeout N <sel>` treats `--timeout` as the
# selector and burns the full default timeout. So this plan uses plan-local
# shims over the real 0.26.x surface (browser.sh's fill/click/open lines are
# unchanged); the shared lib is NOT edited — other plans depend on it.
if ! npx agent-browser session >/dev/null 2>&1; then
warden_skip "rendered shell in a real browser" "npx agent-browser is unavailable on this box"
else
wb_count_gt0() {
local n
n="$(npx agent-browser get count "$1" 2>/dev/null | tr -dc '0-9')"
[ "${n:-0}" -ge 1 ]
}
# Idempotency: drop any prior browser session so the login FORM is actually
# exercised (an authed browser skips /login via the session redirect).
npx agent-browser cookies clear >/dev/null 2>&1
# Login through the real form (browser.sh's login flow, 0.26.x-safe waits).
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']" "$SHELL_EMAIL" >/dev/null
npx agent-browser fill "input[type='password'], input[name='password']" "$SHELL_PASSWORD" >/dev/null
npx agent-browser click "button[type='submit']" >/dev/null
# better-auth's session atom refreshes async; the login page redirects via
# useEffect once useSession() flips. Poll the URL out of /login (≤20s).
LOGGED_IN=0
for _ in $(seq 1 20); do
case "$(npx agent-browser get url 2>/dev/null)" in
''|*'/login'*) sleep 1 ;;
*) LOGGED_IN=1; break ;;
esac
done
[ "$LOGGED_IN" = "1" ] \
&& warden_pass "the real /login form authenticates and redirects off /login" \
|| warden_fail "the /login form did not authenticate — still on $(npx agent-browser get url 2>/dev/null) (origin rejection or session-atom stall)"
# The dual-rail shell on /wiki. Selectors are read from the shipped code,
# not invented: ui/sidebar.tsx stamps data-sidebar="sidebar" on the shell,
# AppSidebar.tsx renders <nav aria-label="Primary"> in the context rail and
# the a[title="Robin"] logo link at the top of the icon rail.
npx agent-browser open "$APP_BASE/wiki" >/dev/null
npx agent-browser wait "[data-sidebar='sidebar']" >/dev/null 2>&1
wb_count_gt0 "[data-sidebar='sidebar']" \
&& warden_pass "the shadcn sidebar landmark ([data-sidebar=sidebar]) renders on /wiki" \
|| warden_fail "no [data-sidebar=sidebar] element on /wiki — the shell did not render"
wb_count_gt0 "nav[aria-label='Primary']" \
&& warden_pass "the context rail's Primary nav landmark renders" \
|| warden_fail "nav[aria-label=Primary] missing — context rail did not render"
wb_count_gt0 "a[title='Robin']" \
&& warden_pass "the icon rail's Robin logo link renders" \
|| warden_fail "a[title=Robin] missing — icon rail did not render"
fi
Step 1 is the load-bearing gate: it drives the SAME seam a browser login uses (app-origin proxy + Origin header + cookie round-trip) with plain curl, so it reds on both known failure classes — a server booted without the app origin in WIKI_ORIGIN (403 INVALID_ORIGIN) and the after-hook blanking auth bodies (200 + null body, fixed in 78d7bef). Steps 2–4 reuse that session. Step 3 is tree-static and runs anywhere; steps 1, 2 and 4 need the live stack. If the shell chrome is redesigned again, re-read the sidebar component for the three step-4 selectors instead of deleting the assertions.
The identity is deliberately self-provisioned per run: plan 06 resets the shared robin_ci database mid-suite, so any hand-seeded credential is dead by the time this plan runs in suite order. Do not swap the warden user for a human-seeded one — that reintroduces the ordering flake. The provisioning rides the product's own gates rather than bypassing them: single-user mode 403s plain sign-ups and blocks member-less sign-ins, but BOTH carve out a pending-invitation path — so the plan seeds exactly one invitation row via SQL and does the rest (sign-up, sign-in, accept-invitation, onboard) over real HTTP. Keep onboarding + membership in step 1: without onboarded_at AuthGuard bounces every shell route to the onboarding wizard, and without a member row it blanks the shell — step 4's rail selectors red spuriously either way.