[object Object]

← back to Grant

Add 3 guest login tiers with capability-based access control

d921396033c7a04553ed0515c330d58e5fa6f54b · 2026-06-01 15:50:26 -0700 · Steve Abrams

- lib/accounts.ts: per-username capability registry (admin + guest/viewer/demo)
- Multi-credential login route (guest passwords from env; absent = disabled)
- Session returns role/isAdmin/canWrite/canUseAI from registry
- Middleware enforces (authoritative): mutating methods need canWrite,
  AI routes need canUseAI -> 403 otherwise
- AuthProvider exposes canWrite/canUseAI; gate Add Grant + all AI Generate
  buttons; header account-tier badge (Guest / Read-only / Demo)

Tiers: guest=member(write,no-AI) viewer=read-only demo=read-only+AI.
Verified all 4 logins + per-tier enforcement + UI gating locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit d921396033c7a04553ed0515c330d58e5fa6f54b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 15:50:26 2026 -0700

    Add 3 guest login tiers with capability-based access control
    
    - lib/accounts.ts: per-username capability registry (admin + guest/viewer/demo)
    - Multi-credential login route (guest passwords from env; absent = disabled)
    - Session returns role/isAdmin/canWrite/canUseAI from registry
    - Middleware enforces (authoritative): mutating methods need canWrite,
      AI routes need canUseAI -> 403 otherwise
    - AuthProvider exposes canWrite/canUseAI; gate Add Grant + all AI Generate
      buttons; header account-tier badge (Guest / Read-only / Demo)
    
    Tiers: guest=member(write,no-AI) viewer=read-only demo=read-only+AI.
    Verified all 4 logins + per-tier enforcement + UI gating locally.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 app/api/auth/login/route.ts                     |  97 ++++++++++++++--------
 app/api/auth/session/route.ts                   |   2 +
 components/AppShell.tsx                         |  31 ++++++-
 components/AuthProvider.tsx                     |  16 +++-
 components/collaborations/CollaborationsTab.tsx |  23 ++++--
 components/grants/GrantsTab.tsx                 |  12 +--
 components/grants/ProposalModal.tsx             |  32 +++++---
 components/outreach/OutreachTab.tsx             |  24 ++++--
 lib/accounts.ts                                 | 103 ++++++++++++++++++++++++
 lib/auth.ts                                     |  91 ++++++++++-----------
 middleware.ts                                   |  24 ++++++
 11 files changed, 340 insertions(+), 115 deletions(-)

diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 730df90..072c14b 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -16,6 +16,7 @@
 import { NextRequest, NextResponse } from 'next/server';
 import crypto from 'crypto';
 import { createSession, buildAuthCookie } from '@/lib/auth';
+import { GUEST_USERNAMES, GUEST_PASSWORD_ENV } from '@/lib/accounts';
 import {
   checkLoginAttempt,
   recordLoginFailure,
@@ -37,7 +38,21 @@ interface CredentialRecord {
   hash:     Buffer;
 }
 
-function loadCredentials(): CredentialRecord {
+function makeRecord(username: string, password: string): CredentialRecord {
+  const salt = crypto.randomBytes(16);
+  return { username, salt, hash: deriveHash(password, salt) };
+}
+
+/**
+ * Load every valid login credential at module load:
+ *   - the admin account (AUTH_USERNAME/AUTH_PASSWORD), and
+ *   - any guest tier (guest/viewer/demo) whose password env var is set.
+ *
+ * A guest tier with no password env is simply not a login — it never enters
+ * the list, so it cannot authenticate. Capabilities for each username live in
+ * lib/accounts.ts and are enforced in middleware.
+ */
+function loadCredentialList(): CredentialRecord[] {
   const isProd  = process.env.NODE_ENV === 'production';
   const usernameRaw = process.env.AUTH_USERNAME;
   const passwordRaw = process.env.AUTH_PASSWORD;
@@ -57,40 +72,59 @@ function loadCredentials(): CredentialRecord {
 
   const username = usernameRaw || 'admin';
   const password = passwordRaw || 'DWSecure2024!';
-
   if (!passwordRaw) {
     console.warn(`[auth] dev fallback: AUTH_PASSWORD not set, using default`);
   }
 
-  const salt = crypto.randomBytes(16);
-  const hash = deriveHash(password, salt);
-  // 2026-05-30 (audit P1-1): do NOT delete process.env.AUTH_PASSWORD here.
-  // lib/sister-auth.ts reads it at module-load to derive SISTER_AUTH; module
-  // eval order is non-deterministic, so deleting it could silently zero out
-  // sister-auth (a latent auth-bypass once a sister route exists). The env is
-  // already readable from process start, so the "scrub" bought almost nothing.
-
-  return { username, salt, hash };
+  const records: CredentialRecord[] = [makeRecord(username, password)];
+
+  // Guest tiers — only when their password env is present.
+  for (const guest of GUEST_USERNAMES) {
+    const pw = process.env[GUEST_PASSWORD_ENV[guest]];
+    if (pw && pw.length > 0) {
+      // A guest username must never collide with the admin username.
+      if (guest === username) {
+        console.warn(`[auth] guest tier "${guest}" collides with AUTH_USERNAME; skipping`);
+        continue;
+      }
+      records.push(makeRecord(guest, pw));
+    }
+  }
+
+  console.log(`[auth] loaded ${records.length} login account(s): ${records.map(r => r.username).join(', ')}`);
+  // 2026-05-30 (audit P1-1): do NOT delete process.env.AUTH_PASSWORD here
+  // (lib/sister-auth.ts reads it at module-load). The env is readable from
+  // process start, so scrubbing buys almost nothing.
+  return records;
 }
 
-const CRED = loadCredentials();
+const CREDS = loadCredentialList();
 
-function verifyPassword(submitted: string): boolean {
-  const candidate = deriveHash(submitted, CRED.salt);
-  // Lengths are equal by construction (always SCRYPT_KEYLEN bytes).
-  return crypto.timingSafeEqual(candidate, CRED.hash);
+// Constant-time string compare that doesn't early-return on length.
+function ctEqualString(a: string, b: string): boolean {
+  const ab = Buffer.from(a);
+  const bb = Buffer.from(b);
+  if (ab.length !== bb.length) {
+    crypto.timingSafeEqual(ab, ab); // burn equivalent cycles
+    return false;
+  }
+  return crypto.timingSafeEqual(ab, bb);
 }
 
-// Constant-time username compare too — small payoff but consistent posture.
-function verifyUsername(submitted: string): boolean {
-  const a = Buffer.from(submitted);
-  const b = Buffer.from(CRED.username);
-  if (a.length !== b.length) {
-    // Burn a few cycles either way to avoid trivial length-based timing.
-    crypto.timingSafeEqual(a, a);
-    return false;
+/**
+ * Verify a submitted username+password against the credential list.
+ * Hashes the password once per record regardless of match so timing does not
+ * leak which usernames exist. Returns the matched username, or null.
+ */
+function verifyCredentials(username: string, password: string): string | null {
+  let matched: string | null = null;
+  for (const rec of CREDS) {
+    const candidate = deriveHash(password, rec.salt);
+    const userOk = ctEqualString(username, rec.username);
+    const passOk = crypto.timingSafeEqual(candidate, rec.hash);
+    if (userOk && passOk) matched = rec.username;
   }
-  return crypto.timingSafeEqual(a, b);
+  return matched;
 }
 
 // ── POST /api/auth/login ──────────────────────────────────────────────────
@@ -127,17 +161,14 @@ export async function POST(request: NextRequest) {
     );
   }
 
-  // 3) Constant-time credential check
-  let ok = true;
-  if (!verifyUsername(username)) ok = false;
-  // Always run the password hash to avoid timing-based username enumeration.
-  if (!verifyPassword(password))  ok = false;
-  if (!ok) return invalidCredentials(ip);
+  // 3) Constant-time credential check against all login accounts
+  const matchedUsername = verifyCredentials(username, password);
+  if (!matchedUsername) return invalidCredentials(ip);
 
-  // 4) Success — clear counter, mint session
+  // 4) Success — clear counter, mint session for the matched account
   clearLoginCounter(ip);
   try {
-    const token       = createSession(CRED.username);
+    const token       = createSession(matchedUsername);
     const cookieValue = buildAuthCookie(token);
     const response    = NextResponse.json({ success: true });
     response.headers.set('Set-Cookie', cookieValue);
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index aa65ee0..976a270 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -17,6 +17,8 @@ export async function GET(request: NextRequest) {
     user: identity.user,
     role: identity.role,
     isAdmin: identity.isAdmin,
+    canWrite: identity.canWrite,
+    canUseAI: identity.canUseAI,
     email: identity.email,
     displayName: identity.displayName,
   });
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index e38b1d4..158a4da 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -25,7 +25,16 @@ const CURRENT_PORT = 7450;
 
 /* ─── Inner shell (needs auth context) ──────────────────────────────────── */
 function Shell() {
-  const { user, logout } = useAuth();
+  const { user, logout, isAdmin, role, canUseAI } = useAuth();
+
+  // Tier badge for non-admin (guest) accounts so the limited mode is obvious.
+  const accountBadge = isAdmin
+    ? null
+    : role === 'member'
+      ? { label: 'Guest', color: '#3b82f6' }
+      : canUseAI
+        ? { label: 'Demo · read-only', color: '#7c3aed' }
+        : { label: 'Read-only', color: '#6b7280' };
   const [activeTab, setActiveTab] = useState<TabId>('dashboard');
   const [sidebarOpen, setSidebarOpen] = useState(false);
   const [showAppSwitcher, setShowAppSwitcher] = useState(false);
@@ -97,6 +106,26 @@ function Shell() {
           <div className="flex items-center gap-3">
             {/* Live / Admin view toggle (admins only) */}
             <ViewModeToggle />
+            {/* Account tier badge (guest accounts only) */}
+            {accountBadge && (
+              <span
+                title={`Signed in as a ${accountBadge.label} account`}
+                style={{
+                  display: 'inline-flex',
+                  alignItems: 'center',
+                  padding: '3px 9px',
+                  borderRadius: 9999,
+                  fontSize: '0.7rem',
+                  fontWeight: 700,
+                  letterSpacing: '0.02em',
+                  textTransform: 'uppercase',
+                  color: '#fff',
+                  backgroundColor: accountBadge.color,
+                }}
+              >
+                {accountBadge.label}
+              </span>
+            )}
             {/* App Switcher */}
             <div style={{ position: 'relative' }}>
               <button
diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx
index e4528e1..9140051 100644
--- a/components/AuthProvider.tsx
+++ b/components/AuthProvider.tsx
@@ -22,6 +22,10 @@ interface AuthContextValue {
   role: Role;
   /** True when the session maps to an owner/admin users-table account. */
   isAdmin: boolean;
+  /** May mutate data (non-AI writes). Read-only guest tiers are false. */
+  canWrite: boolean;
+  /** May use AI discover/generate features. */
+  canUseAI: boolean;
   loading: boolean;
   /** Current header toggle position. Forced to 'live' for non-admins. */
   viewMode: ViewMode;
@@ -43,6 +47,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   const [user, setUser]       = useState<string | null>(null);
   const [role, setRole]       = useState<Role>(null);
   const [isAdmin, setIsAdmin] = useState(false);
+  const [canWrite, setCanWrite] = useState(false);
+  const [canUseAI, setCanUseAI] = useState(false);
   const [loading, setLoading] = useState(true);
 
   // Default to 'admin' (controls visible) for the operator; persisted choice
@@ -72,13 +78,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
           setUser(data.user);
           setRole(data.role ?? null);
           setIsAdmin(Boolean(data.isAdmin));
+          setCanWrite(Boolean(data.canWrite));
+          setCanUseAI(Boolean(data.canUseAI));
           return true;
         }
       }
-      setUser(null); setRole(null); setIsAdmin(false);
+      setUser(null); setRole(null); setIsAdmin(false); setCanWrite(false); setCanUseAI(false);
       return false;
     } catch {
-      setUser(null); setRole(null); setIsAdmin(false);
+      setUser(null); setRole(null); setIsAdmin(false); setCanWrite(false); setCanUseAI(false);
       return false;
     }
   }, []);
@@ -126,7 +134,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   /* Logout */
   const logout = useCallback(async () => {
     await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
-    setUser(null); setRole(null); setIsAdmin(false);
+    setUser(null); setRole(null); setIsAdmin(false); setCanWrite(false); setCanUseAI(false);
     router.replace('/login');
   }, [router]);
 
@@ -163,6 +171,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
         user,
         role,
         isAdmin,
+        canWrite,
+        canUseAI,
         loading,
         viewMode: effectiveMode,
         setViewMode,
diff --git a/components/collaborations/CollaborationsTab.tsx b/components/collaborations/CollaborationsTab.tsx
index 78cf884..d0db083 100644
--- a/components/collaborations/CollaborationsTab.tsx
+++ b/components/collaborations/CollaborationsTab.tsx
@@ -504,6 +504,7 @@ function OutreachGenerateModal({
   onClose: () => void;
 }) {
   const { addToast } = useToast();
+  const { canUseAI } = useAuth();
   const modalRef = useRef<HTMLDivElement>(null);
   const previousFocusRef = useRef<HTMLElement | null>(null);
 
@@ -657,14 +658,20 @@ function OutreachGenerateModal({
                 style={{ resize: 'vertical' }}
               />
             </div>
-            <button
-              className="btn btn-primary w-full"
-              onClick={handleGenerate}
-              disabled={generating}
-            >
-              {generating ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
-              {generating ? 'Generating...' : 'Generate Email'}
-            </button>
+            {canUseAI ? (
+              <button
+                className="btn btn-primary w-full"
+                onClick={handleGenerate}
+                disabled={generating}
+              >
+                {generating ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
+                {generating ? 'Generating...' : 'Generate Email'}
+              </button>
+            ) : (
+              <p className="text-xs text-center" style={{ color: 'var(--color-text-muted)' }}>
+                AI email drafting isn’t available for this account.
+              </p>
+            )}
           </div>
         ) : (
           <div className="space-y-3">
diff --git a/components/grants/GrantsTab.tsx b/components/grants/GrantsTab.tsx
index 511bc9c..2e7197a 100644
--- a/components/grants/GrantsTab.tsx
+++ b/components/grants/GrantsTab.tsx
@@ -192,7 +192,7 @@ function DeadlineBadge({ deadline }: { deadline: string | null }) {
 /* ─── Main Component ─────────────────────────────────────────────────────── */
 export default function GrantsTab() {
   const { addToast } = useToast();
-  const { showAdmin } = useAuth();
+  const { showAdmin, canWrite } = useAuth();
   const [grants, setGrants] = useState<Grant[]>([]);
   const [statusCounts, setStatusCounts] = useState<Record<string, number>>({});
   const [loading, setLoading] = useState(true);
@@ -316,10 +316,12 @@ export default function GrantsTab() {
               {discovering ? 'Discovering...' : 'AI Discover Grants'}
             </button>
           )}
-          <button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
-            <Plus size={15} />
-            Add Grant
-          </button>
+          {canWrite && (
+            <button className="btn btn-primary" onClick={() => setShowAddModal(true)}>
+              <Plus size={15} />
+              Add Grant
+            </button>
+          )}
         </div>
       </div>
 
diff --git a/components/grants/ProposalModal.tsx b/components/grants/ProposalModal.tsx
index 0e47702..e118502 100644
--- a/components/grants/ProposalModal.tsx
+++ b/components/grants/ProposalModal.tsx
@@ -13,6 +13,7 @@ import {
   Send,
 } from 'lucide-react';
 import { useToast } from '../ToastProvider';
+import { useAuth } from '../AuthProvider';
 
 interface Grant {
   id: string;
@@ -65,6 +66,7 @@ export default function ProposalModal({
   onStatusChange,
 }: ProposalModalProps) {
   const { addToast } = useToast();
+  const { canUseAI } = useAuth();
   const modalRef = useRef<HTMLDivElement>(null);
   const previousFocusRef = useRef<HTMLElement | null>(null);
 
@@ -332,18 +334,24 @@ export default function ProposalModal({
                 style={{ resize: 'vertical' }}
               />
             </div>
-            <button
-              className="btn btn-primary w-full"
-              onClick={handleGenerate}
-              disabled={generating}
-            >
-              {generating ? (
-                <Loader2 size={15} className="animate-spin" />
-              ) : (
-                <Sparkles size={15} />
-              )}
-              {generating ? 'Generating with AI...' : 'Generate Proposal'}
-            </button>
+            {canUseAI ? (
+              <button
+                className="btn btn-primary w-full"
+                onClick={handleGenerate}
+                disabled={generating}
+              >
+                {generating ? (
+                  <Loader2 size={15} className="animate-spin" />
+                ) : (
+                  <Sparkles size={15} />
+                )}
+                {generating ? 'Generating with AI...' : 'Generate Proposal'}
+              </button>
+            ) : (
+              <p className="text-xs text-center" style={{ color: 'var(--color-text-muted)' }}>
+                AI proposal drafting isn’t available for this account.
+              </p>
+            )}
           </div>
         )}
 
diff --git a/components/outreach/OutreachTab.tsx b/components/outreach/OutreachTab.tsx
index 96b17fe..811a4d4 100644
--- a/components/outreach/OutreachTab.tsx
+++ b/components/outreach/OutreachTab.tsx
@@ -19,6 +19,7 @@ import {
   ChevronUp,
 } from 'lucide-react';
 import { useToast } from '../ToastProvider';
+import { useAuth } from '../AuthProvider';
 import SortDropdown from '../shared/SortDropdown';
 import { useClientSort, SortConfig } from '@/hooks/useClientSort';
 
@@ -368,6 +369,7 @@ function GenerateTemplateModal({
   onCreated: () => void;
 }) {
   const { addToast } = useToast();
+  const { canUseAI } = useAuth();
   const modalRef = useRef<HTMLDivElement>(null);
   const previousFocusRef = useRef<HTMLElement | null>(null);
 
@@ -558,14 +560,20 @@ function GenerateTemplateModal({
                 style={{ resize: 'vertical' }}
               />
             </div>
-            <button
-              className="btn btn-primary w-full"
-              onClick={handleGenerate}
-              disabled={generating}
-            >
-              {generating ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
-              {generating ? 'Generating...' : 'Generate Template'}
-            </button>
+            {canUseAI ? (
+              <button
+                className="btn btn-primary w-full"
+                onClick={handleGenerate}
+                disabled={generating}
+              >
+                {generating ? <Loader2 size={15} className="animate-spin" /> : <Sparkles size={15} />}
+                {generating ? 'Generating...' : 'Generate Template'}
+              </button>
+            ) : (
+              <p className="text-xs text-center" style={{ color: 'var(--color-text-muted)' }}>
+                AI generation isn’t available for this account.
+              </p>
+            )}
           </div>
         ) : (
           <div className="space-y-3">
diff --git a/lib/accounts.ts b/lib/accounts.ts
new file mode 100644
index 0000000..8b6065c
--- /dev/null
+++ b/lib/accounts.ts
@@ -0,0 +1,103 @@
+/**
+ * Account capability registry — the single source of truth for what each
+ * login can do. Keyed by the app-gate username carried in the session token.
+ *
+ * The app-gate (lib/auth) only knows a username; this maps that username to a
+ * capability set. Enforced server-side in middleware.ts (authoritative) and
+ * mirrored client-side via AuthProvider for UI affordances.
+ *
+ * Pure module — NO database or node-only imports — so it is safe to import
+ * from middleware.
+ *
+ * Accounts:
+ *   admin   — full operator (existing AUTH_USERNAME login).
+ *   guest   — member tier: clean Live view, can add/edit data, no admin controls.
+ *   viewer  — fully read-only: can view everything, cannot mutate anything.
+ *   demo    — read-only data, but AI Discover/Generate routes are usable (demo).
+ *
+ * A guest tier is only a usable login when its password env var is set; see
+ * GUEST_PASSWORD_ENV below and the login route. Absent password ⇒ login
+ * disabled, but the capability mapping still applies if a session ever exists.
+ */
+
+export type Role = 'admin' | 'member' | 'viewer';
+
+export interface Capabilities {
+  username: string;
+  role: Role;
+  displayName: string;
+  /** Sees admin controls + the Live/Admin toggle. */
+  isAdmin: boolean;
+  /** May mutate data (non-AI POST/PUT/PATCH/DELETE). */
+  canWrite: boolean;
+  /** May call the AI discover/generate routes. */
+  canUseAI: boolean;
+}
+
+/** The admin username is configurable via env; everything else maps to admin. */
+export const ADMIN_USERNAME = process.env.AUTH_USERNAME || 'admin';
+
+/** Fixed guest usernames. The matching password env gates whether the login exists. */
+export const GUEST_USERNAMES = ['guest', 'viewer', 'demo'] as const;
+export type GuestUsername = (typeof GUEST_USERNAMES)[number];
+
+/** Env var that holds each guest tier's password. */
+export const GUEST_PASSWORD_ENV: Record<GuestUsername, string> = {
+  guest:  'GRANT_GUEST_PASSWORD',
+  viewer: 'GRANT_VIEWER_PASSWORD',
+  demo:   'GRANT_DEMO_PASSWORD',
+};
+
+const GUEST_CAPS: Record<GuestUsername, Omit<Capabilities, 'username'>> = {
+  guest: {
+    role: 'member',
+    displayName: 'Guest',
+    isAdmin: false,
+    canWrite: true,
+    canUseAI: false,
+  },
+  viewer: {
+    role: 'viewer',
+    displayName: 'Viewer (read-only)',
+    isAdmin: false,
+    canWrite: false,
+    canUseAI: false,
+  },
+  demo: {
+    role: 'viewer',
+    displayName: 'Demo',
+    isAdmin: false,
+    canWrite: false,
+    canUseAI: true,
+  },
+};
+
+const ADMIN_CAPS: Omit<Capabilities, 'username'> = {
+  role: 'admin',
+  displayName: 'Site Admin',
+  isAdmin: true,
+  canWrite: true,
+  canUseAI: true,
+};
+
+/**
+ * Resolve capabilities for a session username. Unknown usernames fail safe to
+ * the most-restrictive (read-only, no AI) set — the login route only ever
+ * mints known usernames, so this is purely defensive.
+ */
+export function getCapabilities(username: string): Capabilities {
+  if (username === ADMIN_USERNAME) {
+    return { username, ...ADMIN_CAPS };
+  }
+  if ((GUEST_USERNAMES as readonly string[]).includes(username)) {
+    return { username, ...GUEST_CAPS[username as GuestUsername] };
+  }
+  return {
+    username,
+    role: 'viewer',
+    displayName: username,
+    isAdmin: false,
+    canWrite: false,
+    canUseAI: false,
+  };
+}
diff --git a/lib/auth.ts b/lib/auth.ts
index 674d669..a9b2b86 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -1,5 +1,6 @@
 import { createAuth, type AuthSession } from '@dw/nextjs-admin-login';
 import { query } from './db';
+import { getCapabilities, type Role } from './accounts';
 
 export const auth = createAuth({ cookieName: 'grant-auth' });
 export const {
@@ -35,66 +36,66 @@ export async function resolveOrgId(session: AuthSession): Promise<string | null>
 
 /**
  * Identity returned to the client for the current session: the app-gate
- * username plus the org-scoped users-table account it maps to (if any).
+ * username plus its capability set (see lib/accounts).
  */
 export interface SessionIdentity {
   user: string;
-  role: 'owner' | 'admin' | 'member' | null;
+  role: Role;
   isAdmin: boolean;
+  canWrite: boolean;
+  canUseAI: boolean;
   email: string | null;
   displayName: string | null;
 }
 
 /**
- * "Recognize" the app-gate login as a users-table account.
+ * Resolve the full identity + capabilities for a session.
  *
- * The app-gate is a single env credential (AUTH_USERNAME/PASSWORD); it does
- * not itself carry a role. We resolve the org for the session, then pick the
- * highest-privilege account in that org (owner > admin) as the operator's
- * identity. isAdmin is true when an owner/admin row exists — which is what the
- * Live/Admin view toggle keys off. Falls back to admin-level for the bare
- * env-gate operator if no row is seeded yet, since that login is by definition
- * the site operator.
+ * Capabilities are looked up from lib/accounts by the session username
+ * (admin / guest / viewer / demo). For the admin account we additionally
+ * enrich email/displayName from the org's owner/admin users-table row so the
+ * header shows the real account; guests are entirely registry-defined and are
+ * never in the users table. The registry is the single source of truth for
+ * isAdmin/canWrite/canUseAI — the same values middleware enforces.
  */
 export async function resolveSessionIdentity(
   session: AuthSession,
 ): Promise<SessionIdentity> {
-  const fallback: SessionIdentity = {
-    user: session.username,
-    role: 'admin',
-    isAdmin: true,
-    email: null,
-    displayName: null,
-  };
-
-  try {
-    const orgId = await resolveOrgId(session);
-    if (!orgId) return fallback;
+  const caps = getCapabilities(session.username);
 
-    const res = await query<{
-      role: 'owner' | 'admin' | 'member';
-      email: string;
-      display_name: string | null;
-    }>(
-      `SELECT role, email, display_name
-         FROM users
-        WHERE org_id = $1 AND role IN ('owner', 'admin')
-        ORDER BY (role = 'owner') DESC, created_at ASC
-        LIMIT 1`,
-      [orgId],
-    );
+  let email: string | null = null;
+  let displayName: string | null = caps.displayName;
 
-    if (res.rows.length === 0) return fallback;
-    const row = res.rows[0];
-    return {
-      user: session.username,
-      role: row.role,
-      isAdmin: true,
-      email: row.email,
-      displayName: row.display_name,
-    };
-  } catch {
-    // DB unreachable — never lock the operator out of admin controls.
-    return fallback;
+  // Enrich the admin identity from the users table (best-effort).
+  if (caps.isAdmin) {
+    try {
+      const orgId = await resolveOrgId(session);
+      if (orgId) {
+        const res = await query<{ email: string; display_name: string | null }>(
+          `SELECT email, display_name
+             FROM users
+            WHERE org_id = $1 AND role IN ('owner', 'admin')
+            ORDER BY (role = 'owner') DESC, created_at ASC
+            LIMIT 1`,
+          [orgId],
+        );
+        if (res.rows.length > 0) {
+          email = res.rows[0].email;
+          displayName = res.rows[0].display_name ?? displayName;
+        }
+      }
+    } catch {
+      // DB unreachable — fall back to registry display name, stay admin.
+    }
   }
+
+  return {
+    user: session.username,
+    role: caps.role,
+    isAdmin: caps.isAdmin,
+    canWrite: caps.canWrite,
+    canUseAI: caps.canUseAI,
+    email,
+    displayName,
+  };
 }
diff --git a/middleware.ts b/middleware.ts
index 4fe26ec..202add3 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -1,5 +1,6 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { verifyAuthWithOrg } from '@/lib/auth';
+import { getCapabilities } from '@/lib/accounts';
 
 /**
  * Grant middleware — auth gate, request-id injection, rate-limit on AI routes.
@@ -78,6 +79,29 @@ export function middleware(request: NextRequest) {
     }
   }
 
+  // Capability gate (authoritative). Guest tiers are read-only / no-AI to
+  // varying degrees; enforce here so a crafted request can't bypass the
+  // hidden-in-UI affordances. Capabilities come from lib/accounts keyed by
+  // the session username.
+  const caps = getCapabilities(session.username);
+  const isMutating = ['POST', 'PATCH', 'PUT', 'DELETE'].includes(request.method);
+
+  if (isAiRoute(pathname)) {
+    if (!caps.canUseAI) {
+      return NextResponse.json(
+        { error: 'AI features are not available for this account' },
+        { status: 403 },
+      );
+    }
+  } else if (isMutating) {
+    if (!caps.canWrite) {
+      return NextResponse.json(
+        { error: 'This account is read-only' },
+        { status: 403 },
+      );
+    }
+  }
+
   // Rate-limit the Gemini-paid AI routes. Key by orgId+route so two users
   // in the same org share a quota; falls back to username for legacy v1
   // sessions where orgId is null.

← a073178 Remove stray @next/swc-darwin-arm64 hard dependency (broke l  ·  back to Grant  ·  chore: macstudio3 migration — reconcile from mac2 + repoint ecf9cbb →