[object Object]

← back to Dw Nextjs Admin Login

v0.3.0: add config-driven capability layer (guest tiers + middleware gate)

e82caf0ce346edc49215114b0bfef0107aade239 · 2026-06-01 16:07:04 -0700 · Steve Abrams

Additive, opt-in. New exports: createCapabilityRegistry (admin + guest tiers,
scrypt constant-time multi-credential verify, username->capabilities) and
capabilityGate (authoritative per-request check: AI routes need canUseAI,
mutating methods need canWrite). Existing createAuth/createLoginHandler
unchanged, so the 4 not-yet-migrated apps are unaffected. 13/13 tests pass.

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

Files touched

Diff

commit e82caf0ce346edc49215114b0bfef0107aade239
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 1 16:07:04 2026 -0700

    v0.3.0: add config-driven capability layer (guest tiers + middleware gate)
    
    Additive, opt-in. New exports: createCapabilityRegistry (admin + guest tiers,
    scrypt constant-time multi-credential verify, username->capabilities) and
    capabilityGate (authoritative per-request check: AI routes need canUseAI,
    mutating methods need canWrite). Existing createAuth/createLoginHandler
    unchanged, so the 4 not-yet-migrated apps are unaffected. 13/13 tests pass.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 package-lock.json   |   4 +-
 package.json        |   2 +-
 src/capabilities.ts | 214 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/index.ts        |   3 +
 4 files changed, 220 insertions(+), 3 deletions(-)

diff --git a/package-lock.json b/package-lock.json
index 27b41e7..19bf0bd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "@dw/nextjs-admin-login",
-  "version": "0.1.0",
+  "version": "0.3.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "@dw/nextjs-admin-login",
-      "version": "0.1.0",
+      "version": "0.3.0",
       "devDependencies": {
         "@types/node": "^22",
         "tsx": "^4",
diff --git a/package.json b/package.json
index bbab1de..3df47c1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@dw/nextjs-admin-login",
-  "version": "0.2.0",
+  "version": "0.3.0",
   "private": true,
   "type": "module",
   "main": "dist/index.js",
diff --git a/src/capabilities.ts b/src/capabilities.ts
new file mode 100644
index 0000000..cfea3a7
--- /dev/null
+++ b/src/capabilities.ts
@@ -0,0 +1,214 @@
+/**
+ * @dw/nextjs-admin-login — capability layer (added v0.3.0)
+ *
+ * Reusable, config-driven capability model for the DW Next.js verticals
+ * (Freddy / Grant / Hub / Patty / PoppyPetitions). Lets an app define an
+ * admin login plus any number of lower-privilege "guest tier" logins, then:
+ *
+ *   - resolve a session username → capability set (getCapabilities),
+ *   - verify a submitted username+password against every active login
+ *     (verify), scrypt-hashed and constant-time, and
+ *   - gate a request by method / AI-route in middleware (capabilityGate).
+ *
+ * This module is PURE (only node:crypto) so it is safe to import from
+ * Next.js middleware (nodejs runtime). It is additive: existing exports
+ * (createAuth / createLoginHandler) are unchanged, so apps that don't opt in
+ * are unaffected.
+ *
+ * Each guest tier is only an active login when its `passwordEnv` is set in
+ * the environment — an unset password means that tier cannot authenticate.
+ */
+
+import crypto from 'node:crypto';
+
+export type CapabilityRole = 'admin' | 'member' | 'viewer';
+
+export interface Capabilities {
+  username: string;
+  role: CapabilityRole;
+  displayName: string;
+  /** Sees admin controls / any admin-only UI affordance. */
+  isAdmin: boolean;
+  /** May mutate data (non-AI POST/PUT/PATCH/DELETE). */
+  canWrite: boolean;
+  /** May call AI (discover/generate) routes. */
+  canUseAI: boolean;
+}
+
+/** A lower-privilege login tier, keyed by username in CapabilityRegistryConfig.guests. */
+export interface GuestTier {
+  role: CapabilityRole;
+  displayName: string;
+  canWrite: boolean;
+  canUseAI: boolean;
+  /** Env var holding this tier's password. Absent/empty ⇒ tier disabled. */
+  passwordEnv: string;
+}
+
+export interface CapabilityRegistryConfig {
+  /** Env var holding the admin username. Default 'AUTH_USERNAME'. */
+  adminUsernameEnv?: string;
+  /** Fallback admin username when the env is unset. Default 'admin'. */
+  defaultAdminUsername?: string;
+  /** Env var holding the admin password. Default 'AUTH_PASSWORD'. */
+  adminPasswordEnv?: string;
+  /** Display name for the admin account. Default 'Site Admin'. */
+  adminDisplayName?: string;
+  /** Guest tiers keyed by username (e.g. { guest: {...}, viewer: {...} }). */
+  guests?: Record<string, GuestTier>;
+}
+
+export interface CapabilityRegistry {
+  /** The resolved admin username. */
+  readonly adminUsername: string;
+  /** Usernames of every active login (admin + guests with a password set). */
+  readonly activeUsernames: string[];
+  /** Resolve capabilities for a session username. Unknown ⇒ most-restrictive. */
+  getCapabilities(username: string): Capabilities;
+  /**
+   * Constant-time multi-credential check. Hashes the password once per active
+   * login regardless of match so timing does not leak which usernames exist.
+   * Returns the matched username, or null.
+   */
+  verify(username: string, password: string): string | null;
+}
+
+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 });
+}
+
+function constantTimeEqualString(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);
+}
+
+interface CredRecord {
+  username: string;
+  salt: Buffer;
+  hash: Buffer;
+  caps: Omit<Capabilities, 'username'>;
+}
+
+/**
+ * Build a capability registry from config + the current environment. Call once
+ * at module load in the consumer; it snapshots passwords/usernames from env at
+ * construction time (matching how the rest of this package reads env).
+ */
+export function createCapabilityRegistry(cfg: CapabilityRegistryConfig = {}): CapabilityRegistry {
+  const adminUsername =
+    process.env[cfg.adminUsernameEnv ?? 'AUTH_USERNAME'] || cfg.defaultAdminUsername || 'admin';
+  const adminPassword = process.env[cfg.adminPasswordEnv ?? 'AUTH_PASSWORD'];
+  const adminDisplayName = cfg.adminDisplayName ?? 'Site Admin';
+  const guests = cfg.guests ?? {};
+
+  const adminCaps: Omit<Capabilities, 'username'> = {
+    role: 'admin',
+    displayName: adminDisplayName,
+    isAdmin: true,
+    canWrite: true,
+    canUseAI: true,
+  };
+
+  // Capability lookup is independent of whether a login is active, so a live
+  // session always resolves correctly even mid password-rotation.
+  const capsByUsername = new Map<string, Omit<Capabilities, 'username'>>();
+  capsByUsername.set(adminUsername, adminCaps);
+  for (const [username, tier] of Object.entries(guests)) {
+    if (username === adminUsername) continue; // never let a guest shadow admin
+    capsByUsername.set(username, {
+      role: tier.role,
+      displayName: tier.displayName,
+      isAdmin: false,
+      canWrite: tier.canWrite,
+      canUseAI: tier.canUseAI,
+    });
+  }
+
+  // Active credential records (only logins with a password present).
+  const records: CredRecord[] = [];
+  if (adminPassword) {
+    const salt = crypto.randomBytes(16);
+    records.push({ username: adminUsername, salt, hash: deriveHash(adminPassword, salt), caps: adminCaps });
+  }
+  for (const [username, tier] of Object.entries(guests)) {
+    if (username === adminUsername) continue;
+    const pw = process.env[tier.passwordEnv];
+    if (pw && pw.length > 0) {
+      const salt = crypto.randomBytes(16);
+      records.push({ username, salt, hash: deriveHash(pw, salt), caps: capsByUsername.get(username)! });
+    }
+  }
+
+  function getCapabilities(username: string): Capabilities {
+    const caps = capsByUsername.get(username);
+    if (caps) return { username, ...caps };
+    // Unknown username — fail safe to the most restrictive set.
+    return { username, role: 'viewer', displayName: username, isAdmin: false, canWrite: false, canUseAI: false };
+  }
+
+  function verify(username: string, password: string): string | null {
+    let matched: string | null = null;
+    for (const rec of records) {
+      const candidate = deriveHash(password, rec.salt);
+      const userOk = constantTimeEqualString(username, rec.username);
+      const passOk = crypto.timingSafeEqual(candidate, rec.hash);
+      if (userOk && passOk) matched = rec.username;
+    }
+    return matched;
+  }
+
+  return {
+    adminUsername,
+    activeUsernames: records.map((r) => r.username),
+    getCapabilities,
+    verify,
+  };
+}
+
+// ── Middleware capability gate ──────────────────────────────────────────────
+
+export interface CapabilityGateInput {
+  capabilities: Capabilities;
+  /** HTTP method of the request. */
+  method: string;
+  /** Whether this path is an AI (paid generate/discover) route. */
+  isAiRoute: boolean;
+}
+
+export interface CapabilityGateResult {
+  /** true ⇒ allow; false ⇒ block with status/error. */
+  ok: boolean;
+  status?: number;
+  error?: string;
+}
+
+const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
+
+/**
+ * Authoritative per-request capability check for middleware:
+ *   - AI routes require canUseAI,
+ *   - other mutating methods require canWrite,
+ *   - everything else (reads) is allowed.
+ * Returns { ok:false, status, error } to block, or { ok:true } to allow.
+ */
+export function capabilityGate(input: CapabilityGateInput): CapabilityGateResult {
+  const { capabilities, method, isAiRoute } = input;
+  if (isAiRoute) {
+    if (!capabilities.canUseAI) {
+      return { ok: false, status: 403, error: 'AI features are not available for this account' };
+    }
+    return { ok: true };
+  }
+  if (MUTATING.has(method) && !capabilities.canWrite) {
+    return { ok: false, status: 403, error: 'This account is read-only' };
+  }
+  return { ok: true };
+}
diff --git a/src/index.ts b/src/index.ts
index 02a6763..66078c4 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -11,6 +11,9 @@
 
 import { createHash, timingSafeEqual } from 'node:crypto';
 
+// v0.3.0 — capability layer (guest tiers + middleware gate). Additive; opt-in.
+export * from './capabilities.js';
+
 // ---------------------------------------------------------------------------
 // Public types
 // ---------------------------------------------------------------------------

← 8acca81 initial scaffold (gitify-all 2026-05-06)  ·  back to Dw Nextjs Admin Login  ·  (newest)