← 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
M app/api/auth/login/route.tsM app/api/auth/session/route.tsM components/AppShell.tsxM components/AuthProvider.tsxM components/collaborations/CollaborationsTab.tsxM components/grants/GrantsTab.tsxM components/grants/ProposalModal.tsxM components/outreach/OutreachTab.tsxA lib/accounts.tsM lib/auth.tsM middleware.ts
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 →