← 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
M app/api/auth/login/route.tsM app/api/auth/session/route.tsM components/AppShell.tsxM components/AuthProvider.tsxA lib/accounts.tsM middleware.tsM package-lock.jsonM package.json
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 →