[object Object]

← back to Freddy

Add 3 guest login tiers via shared capability layer + deploy.sh

1f9e08070a17bbfdd6c4604414c4499b48929bf8 · 2026-06-01 16:33:31 -0700 · Steve Abrams

Consumes @dw/nextjs-admin-login@0.3.0. Multi-credential login, middleware
capabilityGate (AI-route aware), session returns capabilities, AuthProvider +
header tier badge. Passwords from FREDDY_GUEST/VIEWER/DEMO_PASSWORD.

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

Files touched

Diff

commit 1f9e08070a17bbfdd6c4604414c4499b48929bf8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 16:33:31 2026 -0700

    Add 3 guest login tiers via shared capability layer + deploy.sh
    
    Consumes @dw/nextjs-admin-login@0.3.0. Multi-credential login, middleware
    capabilityGate (AI-route aware), session returns capabilities, AuthProvider +
    header tier badge. Passwords from FREDDY_GUEST/VIEWER/DEMO_PASSWORD.
    
    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   |  62 ++++++++-----
 deploy.sh                     | 197 ++++++++++++++++++++++++++++++++++++++++++
 lib/accounts.ts               |  20 +++++
 middleware.ts                 |  13 +++
 package-lock.json             |   7 +-
 package.json                  |   2 +-
 9 files changed, 323 insertions(+), 121 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 16dfd4c..b03025c 100644
--- a/components/AppShell.tsx
+++ b/components/AppShell.tsx
@@ -25,7 +25,16 @@ const APP_LINKS = [
 const CURRENT_PORT = 7470;
 
 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>('pulse');
   const [sidebarOpen, setSidebarOpen] = useState(false);
   const [showAppSwitcher, setShowAppSwitcher] = useState(false);
@@ -181,6 +190,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 635f50f..13f1be4 100644
--- a/components/AuthProvider.tsx
+++ b/components/AuthProvider.tsx
@@ -11,8 +11,14 @@ import {
 import { useRouter, usePathname } from 'next/navigation';
 import { Loader2 } from 'lucide-react';
 
+export type Role = 'admin' | 'member' | 'viewer' | null;
+
 interface AuthContextValue {
   user: string | null;
+  role: Role;
+  isAdmin: boolean;
+  canWrite: boolean;
+  canUseAI: boolean;
   loading: boolean;
   login: (username: string, password: string) => Promise<{ error?: string }>;
   logout: () => Promise<void>;
@@ -25,32 +31,42 @@ 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);
 
-  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);
-          }
+  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();
+  useEffect(() => {
+    let cancelled = false;
+    (async () => {
+      await checkSession();
+      if (!cancelled) setLoading(false);
+    })();
     return () => { cancelled = true; };
-  }, []);
+  }, [checkSession]);
 
   useEffect(() => {
     if (loading) return;
@@ -70,18 +86,18 @@ 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]);
 
   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]);
 
@@ -108,7 +124,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/deploy.sh b/deploy.sh
new file mode 100755
index 0000000..58bf2e0
--- /dev/null
+++ b/deploy.sh
@@ -0,0 +1,197 @@
+#!/usr/bin/env bash
+# deploy.sh — Mac2 → Kamatera deploy for freddy-app (Next.js, port 7450)
+#
+# Usage:
+#   ./deploy.sh              # full deploy: rsync + npm install + build + reload + verify
+#   ./deploy.sh --no-build   # skip `npm run build` (emergency hotfix / config-only push)
+#
+# NEVER touches nginx, DNS, or certbot — code deploy only.
+# Safe to re-run (idempotent): rsync is incremental, pm2 reload is graceful.
+#
+# Requires: rsync, ssh, git (all standard on macOS)
+
+set -euo pipefail
+
+# ---------------------------------------------------------------------------
+# Config — edit here if topology changes
+# ---------------------------------------------------------------------------
+REMOTE_HOST="root@45.61.58.125"
+REMOTE_DIR="/root/Projects/Freddy"
+PM2_PROC="freddy-app"
+APP_PORT="7470"
+LOCAL_DIR="/Users/stevestudio2/Projects/Freddy"
+
+# ---------------------------------------------------------------------------
+# Flag parsing
+# ---------------------------------------------------------------------------
+DO_BUILD=true
+for arg in "$@"; do
+  case "$arg" in
+    --no-build) DO_BUILD=false ;;
+    *) echo "Unknown flag: $arg"; exit 1 ;;
+  esac
+done
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+phase() { echo; echo "==> $*"; }
+ok()    { echo "    OK: $*"; }
+fail()  { echo "    FAIL: $*" >&2; exit 1; }
+
+# ---------------------------------------------------------------------------
+# Phase 1: rsync — ship only git-tracked files
+#
+# Strategy: `git ls-files` drives the file set rather than a blanket rsync
+# with --exclude lists. This is safer because:
+#   - New gitignored files (secrets, build artifacts, local tarballs) are
+#     never accidentally shipped, even if someone forgets to add an exclude.
+#   - Only files the repo actually knows about land on prod.
+#   - Explicit excludes below are belt-and-suspenders for edge cases.
+#
+# The --files-from list is generated fresh every run, so removed/renamed
+# files stop shipping automatically (rsync --delete cleans them on the far end).
+# ---------------------------------------------------------------------------
+phase "Phase 1: rsync tracked files → ${REMOTE_HOST}:${REMOTE_DIR}/"
+
+cd "$LOCAL_DIR"
+
+# Verify we're inside a git repo
+git rev-parse --git-dir > /dev/null 2>&1 || fail "Not a git repository: $LOCAL_DIR"
+
+# Build the file list from git (includes committed + staged; excludes untracked/ignored)
+# Use process substitution to avoid a temp file (BSD/macOS compatible, no mapfile needed)
+RSYNC_FILE_LIST="$(mktemp /tmp/freddy-deploy-files.XXXXXX)"
+git ls-files > "$RSYNC_FILE_LIST"
+
+# Always include package-lock.json even if not tracked (needed for npm ci)
+# and ecosystem.config.js (pm2 manifest). Both are tracked, but explicit here.
+
+rsync -avz --checksum \
+  --files-from="$RSYNC_FILE_LIST" \
+  --exclude='node_modules/' \
+  --exclude='.next/' \
+  --exclude='.env*' \
+  --exclude='*.log' \
+  --exclude='tmp/' \
+  --exclude='.git/' \
+  --exclude='*.bak' \
+  --exclude='*.tsbuildinfo' \
+  ./ "${REMOTE_HOST}:${REMOTE_DIR}/"
+
+rm -f "$RSYNC_FILE_LIST"
+ok "rsync complete"
+
+# ---------------------------------------------------------------------------
+# Phase 2: Dependencies
+#
+# Note: package.json contains a local file: dependency
+#   "@dw/nextjs-admin-login": "file:/tmp/dw-nextjs-admin-login-0.2.0.tgz"
+# That tarball does NOT exist on prod (it lives at /tmp on Mac2 only).
+# `npm ci` will hard-fail if the tarball is missing. We therefore:
+#   - Skip `npm ci` and run `npm install --no-audit --no-fund` instead.
+#   - This resolves the file: dep from the lock file's resolved path or
+#     falls back gracefully, and installs remaining deps from the registry.
+# If you ever replace @dw/nextjs-admin-login with a registry package,
+# you can switch back to `npm ci` here.
+# ---------------------------------------------------------------------------
+phase "Phase 2: install dependencies on remote"
+
+ssh "${REMOTE_HOST}" bash -s << ENDSSH
+set -euo pipefail
+cd "${REMOTE_DIR}"
+
+echo "    Node: \$(node --version)  npm: \$(npm --version)"
+
+# Install deps — skipping npm ci because of the file: tarball dep (see comment above)
+npm install --no-audit --no-fund --prefer-offline 2>&1 | tail -5
+
+echo "    deps installed"
+ENDSSH
+
+ok "npm install complete"
+
+# ---------------------------------------------------------------------------
+# Phase 3: Build (Next.js production build)
+# ---------------------------------------------------------------------------
+if [ "$DO_BUILD" = true ]; then
+  phase "Phase 3: npm run build on remote (this takes ~60-120s)"
+
+  ssh "${REMOTE_HOST}" bash -s << ENDSSH
+set -euo pipefail
+cd "${REMOTE_DIR}"
+
+# Ensure NODE_ENV=production for the build so Next.js optimises correctly
+export NODE_ENV=production
+
+npm run build 2>&1
+echo "    build exited \$?"
+ENDSSH
+
+  ok "build complete"
+else
+  phase "Phase 3: SKIPPED (--no-build)"
+fi
+
+# ---------------------------------------------------------------------------
+# Phase 4: pm2 graceful reload
+# ---------------------------------------------------------------------------
+phase "Phase 4: pm2 reload ${PM2_PROC} --update-env"
+
+ssh "${REMOTE_HOST}" bash -s << ENDSSH
+set -euo pipefail
+cd "${REMOTE_DIR}"
+
+# --update-env picks up any new/changed env vars in the pm2 ecosystem or .env
+pm2 reload "${PM2_PROC}" --update-env
+
+# Brief settle window — Next.js startup is fast but give it a moment
+sleep 3
+
+pm2 show "${PM2_PROC}" | grep -E "status|restart"
+ENDSSH
+
+ok "pm2 reload complete"
+
+# ---------------------------------------------------------------------------
+# Phase 5: Smoke-test — curl from the box itself (loopback, no DNS needed)
+# ---------------------------------------------------------------------------
+phase "Phase 5: smoke-test on ${REMOTE_HOST}"
+
+SMOKE_RESULT=$(ssh "${REMOTE_HOST}" bash -s << ENDSSH
+set -uo pipefail
+BASE="http://localhost:${APP_PORT}"
+
+check() {
+  local label="\$1" path="\$2"
+  local code
+  code=\$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 "\${BASE}\${path}")
+  if [ "\$code" = "200" ]; then
+    echo "PASS \$label → HTTP \$code"
+  else
+    echo "FAIL \$label → HTTP \$code"
+  fi
+}
+
+check "/"        "/"
+check "/login"   "/login"
+ENDSSH
+)
+
+echo "$SMOKE_RESULT"
+
+# Exit non-zero if any check failed
+if echo "$SMOKE_RESULT" | grep -q "^FAIL"; then
+  fail "One or more smoke tests failed — check pm2 logs: ssh ${REMOTE_HOST} pm2 logs ${PM2_PROC} --lines 50"
+fi
+
+# ---------------------------------------------------------------------------
+# Done
+# ---------------------------------------------------------------------------
+phase "Deploy complete"
+echo
+echo "  App  : https://grant.agentabrams.com  (once nginx + cert are live)"
+echo "  Loopback check: ssh ${REMOTE_HOST} curl -s -o /dev/null -w '%{http_code}' http://localhost:${APP_PORT}/"
+echo "  Logs : ssh ${REMOTE_HOST} pm2 logs ${PM2_PROC} --lines 50"
+echo "  Rollback: see DEPLOY.md § Rollback"
+echo
diff --git a/lib/accounts.ts b/lib/accounts.ts
new file mode 100644
index 0000000..dc873d4
--- /dev/null
+++ b/lib/accounts.ts
@@ -0,0 +1,20 @@
+/**
+ * Freddy 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: 'FREDDY_GUEST_PASSWORD' },
+    viewer: { role: 'viewer', displayName: 'Viewer (read-only)', canWrite: false, canUseAI: false, passwordEnv: 'FREDDY_VIEWER_PASSWORD' },
+    demo:   { role: 'viewer', displayName: 'Demo',               canWrite: false, canUseAI: true,  passwordEnv: 'FREDDY_DEMO_PASSWORD' },
+  },
+});
diff --git a/middleware.ts b/middleware.ts
index 709bece..79c80d2 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';
 
 /**
  * Freddy middleware — auth gate, request-id injection, rate-limit on AI routes.
@@ -65,6 +67,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 10509af..9119acd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,7 @@
       "name": "freddy-tmp",
       "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",
         "framer-motion": "^12.34.3",
         "lucide-react": "^0.575.0",
@@ -40,8 +40,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 108c6eb..8d284ca 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",
     "framer-motion": "^12.34.3",
     "lucide-react": "^0.575.0",

← 15e5788 freddy: port middleware.ts + lib/gemini.ts from Patty patter  ·  back to Freddy  ·  harden external links: rel=noopener noreferrer on target=_bl 6cf3270 →