← 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
M package-lock.jsonM package.jsonA src/capabilities.tsM src/index.ts
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)