[object Object]

← back to Patty

Add 3 guest login tiers via shared capability layer

2d7ba5cf164ec32b9fbbf02711b9817563813f78 · 2026-06-01 16:17:35 -0700 · Steve Abrams

Consumes @dw/nextjs-admin-login@0.3.0 createCapabilityRegistry + capabilityGate.
Login route now multi-credential (admin + guest/viewer/demo); middleware
enforces canWrite/canUseAI authoritatively; session returns capabilities;
AuthProvider exposes them + header tier badge. Server-enforced; guest passwords
from PATTY_GUEST/VIEWER/DEMO_PASSWORD env.

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

Files touched

Diff

commit 2d7ba5cf164ec32b9fbbf02711b9817563813f78
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 16:17:35 2026 -0700

    Add 3 guest login tiers via shared capability layer
    
    Consumes @dw/nextjs-admin-login@0.3.0 createCapabilityRegistry + capabilityGate.
    Login route now multi-credential (admin + guest/viewer/demo); middleware
    enforces canWrite/canUseAI authoritatively; session returns capabilities;
    AuthProvider exposes them + header tier badge. Server-enforced; guest passwords
    from PATTY_GUEST/VIEWER/DEMO_PASSWORD env.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 app/api/auth/login/route.ts   | 103 ++++++------------------------------------
 app/api/auth/session/route.ts |  16 +++++--
 components/AppShell.tsx       |  24 +++++++++-
 components/AuthProvider.tsx   |  68 ++++++++++++++++++----------
 lib/accounts.ts               |  20 ++++++++
 middleware.ts                 |  13 ++++++
 package-lock.json             |   7 +--
 package.json                  |   2 +-
 8 files changed, 131 insertions(+), 122 deletions(-)

diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts
index 7350fde..5c529dd 100644
--- a/app/api/auth/login/route.ts
+++ b/app/api/auth/login/route.ts
@@ -1,21 +1,16 @@
-// Hardened login route shared across Patty / Grant / Hub / Freddy.
-// Was: plaintext compare of submitted password against
-//   const expectedPassword = process.env.AUTH_PASSWORD ?? 'DWSecure2024!'
-// — no rate-limit, hard-coded fallback in prod, timing-leaky strcmp.
+// Login route — admin + guest-tier logins via the shared capability registry.
 //
-// Now:
-//   1. Refuses to boot under NODE_ENV=production if AUTH_USERNAME/PASSWORD env
-//      are missing (process.exit(2) on first import).
-//   2. Scrypt-hashes the password once at module load + scrubs the plaintext
-//      from process.env; subsequent compares use crypto.timingSafeEqual.
-//   3. Rate-limits POSTs per IP: 10 fails / 15 min → 30 min lockout, returning
-//      429 with Retry-After.
+// Credentials + capabilities live in lib/accounts (createCapabilityRegistry):
+// the admin account plus any guest tier (guest/viewer/demo) whose password env
+// is set. registry.verify is a constant-time scrypt multi-credential check.
+// Rate-limits POSTs per IP: 10 fails / 15 min → 30 min lockout (429 + Retry-After).
 //
-// Date hardened: 2026-05-21
+// Capability enforcement (read-only / no-AI guests) is authoritative in
+// middleware.ts via capabilityGate.
 
 import { NextRequest, NextResponse } from 'next/server';
-import crypto from 'crypto';
 import { createSession, buildAuthCookie } from '@/lib/auth';
+import { registry } from '@/lib/accounts';
 import {
   checkLoginAttempt,
   recordLoginFailure,
@@ -23,74 +18,6 @@ import {
   loginClientIp,
 } from '@/lib/rate-limit';
 
-// ── module-load credential setup ──────────────────────────────────────────
-const SCRYPT_N      = 16384;
-const SCRYPT_KEYLEN = 64;
-
-function deriveHash(plaintext: string, salt: Buffer): Buffer {
-  return crypto.scryptSync(plaintext, salt, SCRYPT_KEYLEN, { N: SCRYPT_N });
-}
-
-interface CredentialRecord {
-  username: string;
-  salt:     Buffer;
-  hash:     Buffer;
-}
-
-function loadCredentials(): CredentialRecord {
-  const isProd  = process.env.NODE_ENV === 'production';
-  const usernameRaw = process.env.AUTH_USERNAME;
-  const passwordRaw = process.env.AUTH_PASSWORD;
-
-  if (isProd && (!usernameRaw || !passwordRaw)) {
-    const missing = [
-      !usernameRaw ? '  - AUTH_USERNAME' : null,
-      !passwordRaw ? '  - AUTH_PASSWORD' : null,
-    ].filter(Boolean).join('\n');
-    console.error(
-      `\n[auth] REFUSING TO BOOT in NODE_ENV=production with missing login credentials:\n${missing}\n\n` +
-      `Set them in .env.local on prod and restart. Silent fallback to a hard-coded\n` +
-      `default password ('DWSecure2024!') in production is not allowed.\n`,
-    );
-    process.exit(2);
-  }
-
-  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);
-  // Best-effort scrub: remove the plaintext from process.env so it doesn't
-  // sit in /proc/<pid>/environ for the life of the process.
-  delete process.env.AUTH_PASSWORD;
-
-  return { username, salt, hash };
-}
-
-const CRED = loadCredentials();
-
-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 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;
-  }
-  return crypto.timingSafeEqual(a, b);
-}
-
 // ── POST /api/auth/login ──────────────────────────────────────────────────
 function invalidCredentials(ip: string): NextResponse {
   recordLoginFailure(ip);
@@ -118,24 +45,20 @@ export async function POST(request: NextRequest) {
   }
   const { username, password } = body;
   if (!username || !password) {
-    // Missing-field is a 400, not a counter-incrementing 401.
     return NextResponse.json(
       { error: 'Username and password are required' },
       { status: 400 },
     );
   }
 
-  // 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 multi-credential check (admin + active guest tiers)
+  const matchedUsername = registry.verify(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 298d70c..dae9bd2 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -1,12 +1,22 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { verifyAuth } from '@/lib/auth';
+import { registry } from '@/lib/accounts';
 
 export async function GET(request: NextRequest) {
   const username = verifyAuth(request);
 
-  if (username) {
-    return NextResponse.json({ authenticated: true, user: username });
+  if (!username) {
+    return NextResponse.json({ authenticated: false }, { status: 401 });
   }
 
-  return NextResponse.json({ authenticated: false }, { status: 401 });
+  const caps = registry.getCapabilities(username);
+  return NextResponse.json({
+    authenticated: true,
+    user: caps.username,
+    role: caps.role,
+    isAdmin: caps.isAdmin,
+    canWrite: caps.canWrite,
+    canUseAI: caps.canUseAI,
+    displayName: caps.displayName,
+  });
 }
diff --git a/components/AppShell.tsx b/components/AppShell.tsx
index dd08b57..bfdf54b 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -26,7 +26,16 @@ const CURRENT_PORT = 7460;
 
 /* ─── 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);
@@ -193,6 +202,19 @@ function Shell() {
                 </>
               )}
             </div>
+            {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>
+            )}
             {user && (
               <span
                 className="text-xs hidden sm:inline"
diff --git a/components/AuthProvider.tsx b/components/AuthProvider.tsx
index afa0798..cbd63bf 100644
--- a/components/AuthProvider.tsx
+++ b/components/AuthProvider.tsx
@@ -12,8 +12,17 @@ import { useRouter, usePathname } from 'next/navigation';
 import { Loader2 } from 'lucide-react';
 
 /* ─── Types ──────────────────────────────────────────────────────────────── */
+export type Role = 'admin' | 'member' | 'viewer' | null;
+
 interface AuthContextValue {
   user: string | null;
+  role: Role;
+  /** Owner/admin account — sees admin-only controls. */
+  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;
   login: (username: string, password: string) => Promise<{ error?: string }>;
   logout: () => Promise<void>;
@@ -28,33 +37,44 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   const pathname = usePathname();
 
   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);
 
-  /* Check session on mount */
-  useEffect(() => {
-    let cancelled = false;
-
-    async function checkSession() {
-      try {
-        const res = await fetch('/api/auth/session', { credentials: 'include' });
-        if (!cancelled) {
-          if (res.ok) {
-            const data = await res.json();
-            setUser(data.authenticated ? data.user : null);
-          } else {
-            setUser(null);
-          }
+  /* Fetch session identity (user + capabilities) */
+  const checkSession = useCallback(async (): Promise<boolean> => {
+    try {
+      const res = await fetch('/api/auth/session', { credentials: 'include' });
+      if (res.ok) {
+        const data = await res.json();
+        if (data.authenticated) {
+          setUser(data.user);
+          setRole(data.role ?? null);
+          setIsAdmin(Boolean(data.isAdmin));
+          setCanWrite(Boolean(data.canWrite));
+          setCanUseAI(Boolean(data.canUseAI));
+          return true;
         }
-      } catch {
-        if (!cancelled) setUser(null);
-      } finally {
-        if (!cancelled) setLoading(false);
       }
+      setUser(null); setRole(null); setIsAdmin(false); setCanWrite(false); setCanUseAI(false);
+      return false;
+    } catch {
+      setUser(null); setRole(null); setIsAdmin(false); setCanWrite(false); setCanUseAI(false);
+      return false;
     }
+  }, []);
 
-    checkSession();
+  /* Check session on mount */
+  useEffect(() => {
+    let cancelled = false;
+    (async () => {
+      await checkSession();
+      if (!cancelled) setLoading(false);
+    })();
     return () => { cancelled = true; };
-  }, []);
+  }, [checkSession]);
 
   /* Redirect unauthenticated users away from protected routes */
   useEffect(() => {
@@ -76,19 +96,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
     const data = await res.json();
 
     if (res.ok && data.success) {
-      setUser(username);
+      await checkSession();
       router.push('/');
       router.refresh();
       return {};
     }
 
     return { error: data.error ?? 'Login failed' };
-  }, [router]);
+  }, [router, checkSession]);
 
   /* Logout */
   const logout = useCallback(async () => {
     await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
-    setUser(null);
+    setUser(null); setRole(null); setIsAdmin(false); setCanWrite(false); setCanUseAI(false);
     router.replace('/login');
   }, [router]);
 
@@ -116,7 +136,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
   }
 
   return (
-    <AuthContext.Provider value={{ user, loading, login, logout }}>
+    <AuthContext.Provider value={{ user, role, isAdmin, canWrite, canUseAI, loading, login, logout }}>
       {children}
     </AuthContext.Provider>
   );
diff --git a/lib/accounts.ts b/lib/accounts.ts
new file mode 100644
index 0000000..b4ed267
--- /dev/null
+++ b/lib/accounts.ts
@@ -0,0 +1,20 @@
+/**
+ * Patty account capabilities — three guest tiers alongside the admin login,
+ * built on the shared @dw/nextjs-admin-login capability layer (v0.3.0).
+ *
+ *   guest  — member: can write, no AI, no admin-only controls.
+ *   viewer — fully read-only.
+ *   demo   — read-only data, but AI features usable.
+ *
+ * A tier is only an active login when its password env var is set. Enforced
+ * authoritatively in middleware via capabilityGate.
+ */
+import { createCapabilityRegistry } from '@dw/nextjs-admin-login';
+
+export const registry = createCapabilityRegistry({
+  guests: {
+    guest:  { role: 'member', displayName: 'Guest',              canWrite: true,  canUseAI: false, passwordEnv: 'PATTY_GUEST_PASSWORD' },
+    viewer: { role: 'viewer', displayName: 'Viewer (read-only)', canWrite: false, canUseAI: false, passwordEnv: 'PATTY_VIEWER_PASSWORD' },
+    demo:   { role: 'viewer', displayName: 'Demo',               canWrite: false, canUseAI: true,  passwordEnv: 'PATTY_DEMO_PASSWORD' },
+  },
+});
diff --git a/middleware.ts b/middleware.ts
index ea34d78..54e388c 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -1,5 +1,7 @@
 import { NextRequest, NextResponse } from 'next/server';
 import { verifyAuth } from '@/lib/auth';
+import { capabilityGate } from '@dw/nextjs-admin-login';
+import { registry } from '@/lib/accounts';
 
 /**
  * Patty middleware — auth gate, request-id injection, rate-limit on AI routes.
@@ -70,6 +72,17 @@ export function middleware(request: NextRequest) {
     return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
   }
 
+  // Capability gate (authoritative): read-only / no-AI guest tiers can't
+  // mutate or hit AI routes regardless of what the UI shows.
+  const gateResult = capabilityGate({
+    capabilities: registry.getCapabilities(username),
+    method: request.method,
+    isAiRoute: isAiRoute(pathname),
+  });
+  if (!gateResult.ok) {
+    return NextResponse.json({ error: gateResult.error }, { status: gateResult.status });
+  }
+
   // Rate-limit the Gemini-paid AI routes. Key by username (single-tenant).
   if (isAiRoute(pathname)) {
     const key = `${username}:${pathname}`;
diff --git a/package-lock.json b/package-lock.json
index a613e23..bbf9ed8 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
       "name": "patty-petitions",
       "version": "0.1.0",
       "dependencies": {
-        "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+        "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.3.0.tgz",
         "@types/pg": "^8.16.0",
         "@types/sanitize-html": "^2.16.1",
         "lucide-react": "^0.575.0",
@@ -41,8 +41,9 @@
       }
     },
     "node_modules/@dw/nextjs-admin-login": {
-      "version": "0.2.0",
-      "resolved": "file:../../../../tmp/dw-nextjs-admin-login-0.2.0.tgz",
+      "version": "0.3.0",
+      "resolved": "file:../../../../tmp/dw-nextjs-admin-login-0.3.0.tgz",
+      "integrity": "sha512-+SAKsFQGXC8M2KcH7jaO9ObL1lYO5GmwDvrmHmFHxhgLzfvH+y0lTlqyVmJd9d/wBLSkQNxUQvNzTYe43+I0gg==",
       "peerDependencies": {
         "next": "^15.0.0 || ^16.0.0"
       }
diff --git a/package.json b/package.json
index 3286af6..d8dc82e 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
     "start": "next start"
   },
   "dependencies": {
-    "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz",
+    "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.3.0.tgz",
     "@types/pg": "^8.16.0",
     "@types/sanitize-html": "^2.16.1",
     "lucide-react": "^0.575.0",

← ca02103 patty: add /pulse — Petition Pulse public visualization (CNC  ·  back to Patty  ·  Add deploy.sh (from Grant template, patty-petitions :7460) da4d191 →