khalidadil
  • Joined on 2025-08-25

@khalidadil/auth-manager (0.4.0)

Published 2026-10-09 05:24:28 +00:00 by khalidadil

Installation

@khalidadil:registry=
npm install @khalidadil/auth-manager@0.4.0
"@khalidadil/auth-manager": "0.4.0"

About this package

@khalidadil/auth-manager

Passwordless auth module: email one-time codes (preferred), legacy magic links, Google SSO for admins, sessions, an address book and a React account shell. Postgres-backed, host-injected pool.

Version 0.4.0. Adds bearer-token sessions, a framework-free node:http adapter (/node), a durable wrong-code budget and an optional resend cooldown for operator codes, and a break-glass session mint for box-local CLIs. Every feature is off by default; the budget and break-glass need the opt-in operator migration set. Default runMigrations, Express and Fastify behave exactly as in 0.3.0 (0.3.0 added the core, the Fastify adapter, schema namespacing, the Redis rate-limit store and the TypeScript declarations).

Package subpaths

Import Exports
@khalidadil/auth-manager React: AuthProvider, useAuthContext, useCustomer, useAdmin, useAddresses, useFacetConfig, useFacetValues, LoginForm, AccountMenu, AccountShell, AccountHomePage, AccountAddressesPage, AddressForm, FacetSelect, FacetPrompt, AccountFacetPage, AdminLoginPage, RequireAdmin
@khalidadil/auth-manager/server (alias /express) mountAuth, runMigrations, findSession, destroySession, getForCustomer, setForCustomer, CUSTOMER_COOKIE, ADMIN_COOKIE
@khalidadil/auth-manager/core createAuthCore, runMigrations, authPoolConfig, assertSchemaName, createMemoryRateLimitStore, createRedisRateLimitStore, SLIDING_WINDOW_LUA, DEFAULT_RATE_LIMITS, generateToken, hashToken, parseBearer, CUSTOMER_COOKIE, ADMIN_COOKIE
@khalidadil/auth-manager/fastify createFastifyAuth, runMigrations, authPoolConfig
@khalidadil/auth-manager/node createNodeAuth, parseBearer, runMigrations, authPoolConfig
@khalidadil/auth-manager/styles.css the bundled stylesheet

/core, /fastify and /node never import express, fastify or React at runtime (the build checks /core and /node for both frameworks). Peers (express, fastify, pg, react, react-dom, react-router-dom) are all optional: a Fastify host installs only pg and fastify, a node:http host only pg.

Install from the Gitea registry (anonymous reads): npm config set @khalidadil:registry https://git.kadil.dev/api/packages/khalidadil/npm/ then npm install @khalidadil/auth-manager@0.4.0 pg. The package is ESM-only.

Core

createAuthCore(opts) composes the shared library (sessions, email codes, principals, email sender, rate limiter) into a framework-neutral object. Every flow returns an HttpResult ({ status, body, headers?, cookies?, after? }) that an adapter serialises; after runs post-response.

import { createAuthCore, createMemoryRateLimitStore } from '@khalidadil/auth-manager/core';

const core = createAuthCore({
  db: pool,                       // required: pg Pool (query + connect)
  appName: 'Filebot',             // required: no brand default in the core
  cookies: { prefix: 'filebot', names: { admin: 'filebot_operator' }, sameSite: { admin: 'strict' } },
  isProduction: process.env.NODE_ENV === 'production',
  schema: 'auth_manager',         // optional; set → core.ready() asserts the pinned pool
  email: { smtp, fromAddress, send, logCodes: 'auto' },
  adminEmails: ['ops@example.test'],   // or (emailLc) => boolean | Promise<boolean>
  adminCodeDigits: 8, adminSessionMaxAgeMs: null,
  rateLimitStore: createMemoryRateLimitStore(), rateLimits: {},
  allowedOrigins: ['https://app.example.test'],
  sessionTransport: 'cookie',     // or 'bearer' (cookies then optional; see Bearer sessions)
  adminVerifyBudget: null,        // or { maxFailures, windowMs }; needs the operator migration set
  adminCodeResendCooldownMs: 0,   // 0 = off
  logger: console,
});
// core.flows.{requestCustomerCode, verifyCustomerCode, requestAdminCode, verifyAdminCode, logout}
// core.{sessions, principals, codes, cookies, email, rateLimit, checkOrigin, ready, cookieNames,
//       sessionTransport, mintBreakGlassSession}

Throws, in order: opts.db (pg Pool) is required, opts.appName is required, opts.sessionTransport must be "cookie" or "bearer", opts.cookies.prefix is required (cookie mode only), then schema-name, adminCodeDigits ∈ {6,7,8}, origin, rate-limit, budget and cooldown validation, and the __Host- rule (__Host- cookie names need isProduction and no domain). No database query happens at construction.

Express — 0.2 → 0.3: nothing to change

mountAuth(app, opts) keeps every 0.2.0 option, default, route, log line, cookie and return value. A 0.2.0 host upgrades by bumping the version. The default mount registers no new routes.

import { Pool } from 'pg';
import { mountAuth, runMigrations } from '@khalidadil/auth-manager/server';

const pgPool = new Pool({ connectionString: process.env.DATABASE_URL });
await runMigrations(pgPool, { logger });          // idempotent, safe on every boot

const { authMiddleware, requireCustomer, requireAdmin, requireCustomerOrAdmin } = mountAuth(app, {
  db: pgPool,
  apiBase: '/api',
  callbackBaseUrl: process.env.PUBLIC_BASE_URL,   // required while magicLinks or googleSso is on
  smtp: process.env.SMTP_HOST ? { host: process.env.SMTP_HOST, port: process.env.SMTP_PORT,
    user: process.env.SMTP_USER, pass: process.env.SMTP_PASS, from: process.env.SMTP_FROM } : undefined,
  fromAddress: 'no-reply@example.com',
  appName: 'Pawfectly Yours',
  googleClientId: process.env.GOOGLE_CLIENT_ID,
  googleClientSecret: process.env.GOOGLE_CLIENT_SECRET,
  adminEmails: (process.env.ADMIN_EMAILS || '').split(',').map((s) => s.trim()).filter(Boolean),
  isProduction: process.env.NODE_ENV === 'production',
  cookieDomain: process.env.COOKIE_DOMAIN,
  logger,
});

Routes under ${apiBase}/auth: POST /customer/request-code, POST /customer/verify-code, POST /customer/request (magic link), POST /customer/logout, POST /admin/logout, GET /me, GET /admin/me; ${apiBase}/account/addresses (CRUD) and ${apiBase}/account/facet-values; at the host root GET /auth/verify and GET /auth/admin/google[/callback]. The customer code routes now run on the core flows with identical responses.

New opt-in options (defaults reproduce 0.2.0): cookiePrefix ('pawfectly'), cookieNames, logCodes ('auto'), sendEmail, rateLimitStore, rateLimits, magicLinks (true), googleSso (true), adminOtp (false), allowedOrigins, adminCodeDigits (8). mountAuth(app, { db, magicLinks: false, googleSso: false }) needs no callbackBaseUrl. adminOtp: true mounts POST /admin/request-code + POST /admin/verify-code under ${apiBase}/auth and requires a non-empty allowedOrigins.

Behaviour delta for hosts with SMTP configured: codes and magic links are no longer written to the log when a transport exists (see Code-logging policy). Hosts without SMTP_HOST are byte-identical.

Fastify

import pg from 'pg';
import { createFastifyAuth, authPoolConfig, runMigrations } from '@khalidadil/auth-manager/fastify';
import { createRedisRateLimitStore } from '@khalidadil/auth-manager/core';

const authPool = new pg.Pool(authPoolConfig({ connectionString: process.env.DATABASE_URL, schema: 'auth_manager', max: 4 }));
await runMigrations(authPool, { schema: 'auth_manager', logger });   // after the host's own migrations

const auth = createFastifyAuth({
  db: authPool, schema: 'auth_manager', appName: 'Filebot', routePrefix: '/console/api/auth',
  isProduction: isProd,
  cookies: { prefix: 'filebot', names: { admin: isProd ? '__Host-filebot_operator' : 'filebot_operator' } },
  adminEmails: operatorEmails,
  allowedOrigins: [new URL(publicBaseUrl).origin, ...devOrigins],
  email: { smtp },
  rateLimitStore: createRedisRateLimitStore(redis, { prefix: 'fbx:auth:rl:' }),
  logger,
});

await app.register(auth.plugin);            // FIRST; its onReady hook runs core.ready() at listen()
app.register(async (c) => {
  c.addHook('preHandler', auth.requireAdmin);
  // console routes…
}, { prefix: '/console/api' });

createFastifyAuth returns { core, plugin, resolve, requireAdmin, optionalAdmin, csrfGuard }. schema (may be 'public') and a non-empty allowedOrigins are required. Adapter defaults: routePrefix '/api/auth', bodyLimit 4096, recheckAllowlist true, admin cookie SameSite=Strict, adminSessionMaxAgeMs 7 days (absolute cap over the 24 h rolling session).

The plugin decorates request.authSession (AuthState | null), adds one onReady hook and registers the routes in an encapsulated child under routePrefix. Nothing else touches the host: no parsers, hooks or error handlers outside the prefix; a host route issues zero pool queries.

Guards are async preHandlers: requireAdmin answers 403 {error:'origin_forbidden'} for an unsafe request that fails the origin rule (before any DB query) and 401 {error:'admin_required'} without an admin session; optionalAdmin only resolves; csrfGuard applies the origin rule alone. resolve(request) is memoised per request. A host needing its own error body composes await auth.resolve(req) and auth.core.checkOrigin({ method, origin, secFetchSite }) itself.

Cookies are read from request.cookies when @fastify/cookie has run, else parsed from the header; they are written as set-cookie headers, so the plugin works with or without @fastify/cookie.

Operator OTP

Route (under routePrefix) Request Responses
POST /admin/request-code {"email"} always 200 {"ok":true}; an IP limit adds Retry-After
POST /admin/verify-code {"email","code"} 200 {"ok":true,"admin":{id,email,displayName}} + admin cookie (24 h, HttpOnly, Secure in prod, SameSite=Strict); 400 {"ok":false,"error":"invalid"} (email shape); 401 {"ok":false,"error":"invalid"} for every code/allowlist failure; 429 too_many_attempts; 503 rate_limit_unavailable; 500 server_error
POST /admin/logout no body 200 {"ok":true} + expired cookie
GET /admin/me cookie 200 {"admin":{…}} / 401 {"error":"admin_required"}
  • Codes are 8 digits (adminCodeDigits), 10-minute TTL, one live code per email, 5 attempts then locked.
  • request-code answers before any allowlist, database or SMTP work; a non-allowlisted email writes nothing and sends nothing, so the response never reveals allowlist membership.
  • Every verify failure other than 429/400/503/500 is exactly 401 invalid, returned no earlier than 250 ms after receipt (uniform timing). The allowlist is re-checked at verify and, with recheckAllowlist, on every guarded request: a removed operator loses all sessions.
  • Request-code and verify-code require Content-Type: application/json (else 415) and a body of at most bodyLimit bytes (else 413). Malformed JSON is 400 invalid.
  • Origin rule on unsafe methods: GET/HEAD/OPTIONS pass; an Origin header must be an exact allowlisted origin (Origin: null is never allowed); otherwise Sec-Fetch-Site must be same-origin or none; a request carrying neither header is allowed.
  • Log events (never the raw email, a code or a token): admin_code.not_allowlisted {emailSha, domain}, admin_code.send_failed, admin_code.budget_exhausted {emailSha}, admin_code.cooldown {emailSha}, admin_login.succeeded {adminId}, admin_login.failed {reason, emailSha} (reason budget_exhausted with a verify budget), admin_session.revoked_not_allowlisted {adminId, count}, admin_session.break_glass_minted {adminId, emailSha, label}, rate_limit.store_error {bucket}.
  • The same routes and responses are served by the /node adapter; in bearer mode verify answers {ok, admin, token, expiresAt} without a cookie and /admin/me answers {admin, expiresAt}.

Bearer sessions

sessionTransport: 'bearer' (core and /node; default 'cookie') moves the session token from a cookie into the response body and the Authorization header. cookies is then optional and no cookie is ever emitted.

  • verifyAdminCode succeeds with 200 {ok:true, admin:{id,email,displayName}, token, expiresAt}; token is 64 lowercase hex and expiresAt is the effective expiry, min(row expires_at, row created_at + adminSessionMaxAgeMs) (rows roll for 24 h). Both values are read back from the new row, so they are on the database clock, the clock the cap is enforced against, and /admin/me reports exactly the same instant.
  • verifyCustomerCode succeeds with {ok:true, next, token, expiresAt} through core.flows; no adapter mounts customer routes in bearer mode.
  • logout answers 200 {ok:true} and deletes the session row (no clear-cookie).
  • parseBearer(header) returns the token only for Bearer <exactly 64 lowercase hex> (scheme in any case, spaces or tabs between, nothing after; the first element of an array header), else null without any database work, so other bearer formats (API keys, app tokens) never reach a lookup.
  • core.sessions.resolveToken(token) resolves a token to {session, customer, admin}, keeps the admin absolute cap, never throws and fails closed to all-null.

A host with its own auth routes drives the core directly: createAuthCore({ …, sessionTransport: 'bearer' }), core.flows.requestAdminCode / verifyAdminCode / logout, and core.sessions.resolveToken(parseBearer(req.headers.authorization)) plus core.checkOrigin in its guard. It writes each HttpResult's status, body and headers (Retry-After), and it must also do what the adapters do for it:

  1. Run after. A request-code result that passed the limiters carries after(): run it once the response is sent (it checks the allowlist, issues the code and mails it, and never throws; a host that never runs it never sends a code), and await the pending runs at shutdown.
  2. Re-check the allowlist on every guarded request. When core.principals.isAdminEmail(state.admin.email) is false, treat the request as unauthenticated and call core.sessions.destroyAllForAdmin(state.admin.id); if the allowlist throws, refuse the request but revoke nothing. resolveToken alone does not re-check, so without this a removed operator's bearer stays valid until the cap.
  3. Send Cache-Control: no-store on every auth response, above all on the verify response that carries the token.

A host's own session probe reports the expiry the server enforces as min(session.expiresAt, session.createdAt + adminSessionMaxAgeMs) over resolveToken's session (both from the row; session.expiresAt alone without a cap). With a cap below the 24 h rolling expiry that is exactly the expiresAt verify returned.

Express's mountAuth stays cookie-only, and createFastifyAuth throws sessionTransport 'bearer' is not supported; use @khalidadil/auth-manager/node.

node:http adapter (/node)

import http from 'node:http';
import pg from 'pg';
import { createNodeAuth, authPoolConfig, runMigrations } from '@khalidadil/auth-manager/node';

const authPool = new pg.Pool(authPoolConfig({ connectionString: process.env.DATABASE_URL, schema: 'console_auth' }));
await runMigrations(authPool, { schema: 'console_auth', include: ['operator'], logger });

const auth = createNodeAuth({
  db: authPool, schema: 'console_auth', appName: 'Console',
  sessionTransport: 'bearer',
  allowedOrigins: ['https://console.example.test'],
  adminEmails: operatorEmails,
  adminCodeDigits: 8,
  adminSessionMaxAgeMs: 12 * 60 * 60 * 1000,                        // hard cap
  adminVerifyBudget: { maxFailures: 5, windowMs: 60 * 60 * 1000 },  // wrong codes per email per hour
  rateLimits: {
    admin_email_request: { max: 5, windowMs: 60 * 60 * 1000 },       // silent: still 200
    admin_ip_request: { max: 10, windowMs: 60 * 60 * 1000 },         // 200 + Retry-After
  },
  email: { send: mailer.send, fromAddress: 'console@example.test', logCodes: 'never' },
  clientIp: (req) => trustedProxyClientIp(req),
  logger,
});
await auth.core.ready();   // AUTH_NOT_MIGRATED when the operator set is missing

const server = http.createServer(async (req, res) => {
  if (await auth.handle(req, res)) return;      // the four admin routes under /api/auth
  if (req.url.startsWith('/api/console/')) {
    const { admin } = await auth.resolve(req);  // Authorization: Bearer <token>
    if (!admin) return reply(res, 401, { ok: false, reason: 'unauthenticated' });
    // …
  }
});
// shutdown: server.close(); await auth.drain(5000);

That is a hardened operator profile: 8-digit codes, 5 code requests per email and 10 per IP per hour, 5 wrong codes per email per hour across every code, and a 12 h session cap.

createNodeAuth(opts) takes the core options plus schema (required, may be 'public'), a non-empty allowedOrigins (required), routePrefix ('/api/auth'), bodyLimit (4096), recheckAllowlist (true), clientIp(req) (default req.socket.remoteAddress; pass a trusted-proxy resolver behind a proxy) and onError(err, req, res) (default: logged, 500 {ok:false,error:'server_error'}, the message never echoed). Its defaults match the Fastify adapter's: admin cookie SameSite=Strict and a 7-day adminSessionMaxAgeMs cap.

It returns { core, handle, resolve, requireAdmin, sendResult, drain }:

  • handle(req, res) → Promise<boolean> serves POST {prefix}/admin/request-code, /admin/verify-code, /admin/logout and GET {prefix}/admin/me with the Operator OTP wire contract above (origin rule first, 415, 413 with Connection: close, 400 for bad JSON, Cache-Control: no-store on every response, headers the host set earlier kept). Anything else returns false untouched, so the host keeps routing.
  • resolve(req) → AuthState, memoised per request; bearer mode reads Authorization, cookie mode the session cookies; the allowlist re-check revokes a removed admin's sessions.
  • requireAdmin(req, res) → the admin, or null after answering 403 {error:'origin_forbidden'} or 401 {error:'admin_required'}. A host that needs its own error body composes resolve and core.checkOrigin, as above.
  • sendResult(req, res, result) writes a core HttpResult for host routes built on core.flows.
  • drain(timeoutMs) awaits post-response work (code delivery); call it at shutdown.

No route mints a break-glass session. In cookie mode the adapter sets and reads the admin cookie and ignores Authorization.

Wrong-code budget and resend cooldown

adminVerifyBudget: { maxFailures, windowMs } (default null = off; maxFailures an integer 1..100, windowMs an integer 60000..86400000) allows at most maxFailures wrong admin codes per email in any sliding window, across all of that email's codes. A new code request, code expiry and a successful login never reset it; only elapsed time does. It is stored in email_verify_failures (one row per email, created only by a guess against a live code), so it survives restarts and is shared by every process on the database.

  • While exhausted, verify answers the uniform 401 invalid (the reason is logged only) and request-code still answers 200 but issues no code (admin_code.budget_exhausted).
  • Malformed codes, missing and expired codes are not charged. Parallel guesses serialize on the budget row, so a burst never charges past the limit. The per-code 5-attempt cap still applies.
  • adminCodeResendCooldownMs (an integer 0..600000; default 0 = off) refuses to issue a new code while one was issued for that email within the window (admin_code.cooldown; still 200).
  • Customer flows and core.codes.verify are unaffected.

Break-glass session mint

core.mintBreakGlassSession({ email, label? }) → { token, sessionId, expiresAt, admin } mints an admin session without OTP, the budget or any rate limit. It exists for a box-local CLI, where shell access to the host already implies control, and no adapter exposes it over HTTP.

import { createAuthCore } from '@khalidadil/auth-manager/core';
const core = createAuthCore({ db: authPool, schema: 'console_auth', appName: 'Console',
  sessionTransport: 'bearer', adminEmails: operatorEmails, adminSessionMaxAgeMs: 12 * 3600_000,
  email: { logCodes: 'never' }, logger });
const { token, expiresAt } = await core.mintBreakGlassSession({ email, label: 'console-cli' });

The email must be on the allowlist (else AUTH_NOT_ALLOWLISTED). The session row records issued_via = 'break_glass', the label (default 'auth-manager break-glass', at most 200 characters) as its user agent and no IP; OTP sessions keep issued_via NULL. A warn admin_session.break_glass_minted {adminId, emailSha, label} is logged; the token never is. Without the operator migration set it throws AUTH_NOT_MIGRATED.

Opt-in migration sets

migrations/*.sql (001-003) is the default set: runMigrations(pool) and runMigrations(pool, { schema }) apply exactly these, as in 0.2.0 and 0.3.0. The operator set, migrations/operator/004_admin_otp_hardening.sql, is applied only on request:

await runMigrations(authPool, { schema: 'console_auth', include: ['operator'] });

It adds email_verify_failures (email PK, fail_at timestamptz[], updated_at) and the nullable column sessions.issued_via, both additive, so 0.2.0 and 0.3.0 code keep working on a schema that has them. The verify budget and break-glass need it.

  • include is validated before anything else: an unknown set throws runMigrations include: unknown migration set "<x>" with no query. A non-empty include takes the advisory lock even without a schema.
  • Ledger ids are file basenames (004_admin_otp_hardening); a duplicate id throws before connecting. 004 is reserved for the operator set; the next default migration is 005.
  • core.ready() checks for the set only when both schema and adminVerifyBudget are set, and then throws AUTH_NOT_MIGRATED naming include: ['operator']. Without a schema, a missing table fails closed: verify answers 500 and request-code issues nothing.

Schema namespacing

authPoolConfig({ connectionString, schema, max = 4, applicationName = 'auth-manager' }) returns a pg.Pool config whose startup parameter options: '-c search_path=<schema>' pins every connection to that schema for its whole life (RESET/DISCARD ALL return to it). Migrations and SQL are unchanged; unqualified names simply resolve inside the schema. <schema>.schema_migrations(id) never touches a host table such as public.schema_migrations(name).

  • assertSchemaName: /^[a-z_][a-z0-9_]{0,62}$/, not pg_*, not information_schema.
  • authPoolConfig throws when the URL already carries options= (pg merges URL params over config).
  • runMigrations(pool, { schema }) runs on one locked client (pg_advisory_lock), creates the schema, asserts current_schemas(false) is exactly [schema], applies pending files and on any error destroys that client (no poisoned connection, lock released). { lock: true } takes the lock without a schema. Without either option the 0.2.0 sequence runs verbatim.
  • core.ready() (called by the Fastify onReady hook) throws AUTH_SCHEMA_MISMATCH on a pool whose search_path is not exactly the schema, AUTH_SCHEMA_IS_ROLE when current_user === schema, and AUTH_NOT_MIGRATED when sessions/email_codes are missing. A host schema_migrations table without an id column raises AUTH_SCHEMA_MIGRATIONS_COLLISION.
  • Connect to Postgres directly: PgBouncer transaction mode strips startup options.

Code-logging policy

hasTransport = !!(smtp.host && smtp.port) || typeof send === 'function'; logsCodes = logCodes === 'auto' && !hasTransport. There is no 'always'.

Transport logCodes Result
none 'auto' (default) 0.2.0 behaviour: code box in the log, magic-link banner on stdout, boot line No SMTP_HOST in env — magic links will only print to console.
none 'never' boot warn no email transport and logCodes=never — logins cannot complete; nothing is sent or logged
SMTP or send any codes and links reach only the email; boot line SMTP transport configured: host:port (login codes and links are not logged) or custom email transport configured (…); a send failure logs SMTP send failed (code not logged) with the code scrubbed from the error text

send({ from, to, subject, text, html, kind: 'login_code' | 'magic_link' }) wins over smtp when both are given. Break-glass for a host that cannot receive mail: unset SMTP_HOST (then 'auto' logs).

Rate-limit stores

Buckets: ip_request 10/h, email_request 3/15 min, ip_verify 20/h, and admin_ip_request, admin_email_request, admin_ip_verify with the same numbers in a separate keyspace (DEFAULT_RATE_LIMITS; override per bucket with rateLimits: { bucket: { max, windowMs } }).

  • createMemoryRateLimitStore() (default): one sliding window per bucket, decided synchronously, identical to 0.2.0's limiter; never throws.
  • createRedisRateLimitStore(client, { prefix = 'auth-manager:rl:' }): client is any ioredis-style eval(script, numKeys, ...args). Key ${prefix}${bucket}:${sha256(identifier).slice(0, 32)} (no raw email or IP), SLIDING_WINDOW_LUA uses Redis TIME as the clock, sets PEXPIRE to the window and does not record denied hits. Tested against redis:7.
  • A custom store { hit(bucket, identifier, rule) → { allowed: true } | { allowed: false, retryAfterMs } } may throw; the flows then fail closed: request-code answers 200 and sends nothing (rate_limit.store_error logged), verify-code answers 503 rate_limit_unavailable.

Types

Hand-written declarations ship in types/ and are wired through exports (types condition first): types/index.d.ts (React entry: components as ComponentType<any>, hooks return any, no @types/react needed), server.d.ts (mountAuth(app: ExpressAppLike, opts: MountAuthOptions): AuthGuards, structural Express types), core.d.ts (AuthCoreBaseOptions, AuthCoreOptions, AuthCore, HttpResult, stores), fastify.d.ts (FastifyAuthOptions, FastifyAuth, the fastify module augmentation interface FastifyRequest { authSession: AuthState | null } — hosts must not redeclare it), node.d.ts (NodeAuthOptions, NodeAuth, only node:http types) and common.d.ts (shared types incl. SessionTransport, VerifyBudget, BreakGlassResult, RunMigrationsOptions.include). AuthCoreOptions is a union: cookie mode (the default) requires cookies, bearer mode makes it optional. FastifyAuthOptions keeps cookies required and accepts only sessionTransport?: 'cookie'. PgPoolLike is structural; a real pg.Pool satisfies it. Verified under NodeNext + strict + skipLibCheck: false by npm run test:types and, from the packed tarball, by npm run test:pack.

Client wiring (unchanged)

import { AuthProvider, LoginForm, AccountShell, AdminLoginPage, RequireAdmin } from '@khalidadil/auth-manager';
import '@khalidadil/auth-manager/styles.css';
<AuthProvider apiBase="/api"><App /></AuthProvider>

buildCookieNames(prefix) yields <prefix>_customer_session (90 d rolling), <prefix>_admin_session (24 h rolling; Fastify adds a 7 d cap), <prefix>_oauth_state and <prefix>_oauth_verifier (10 min). Prefix pawfectly reproduces the 0.2.0 constants CUSTOMER_COOKIE / ADMIN_COOKIE; cookieNames overrides single names (e.g. __Host-filebot_operator in production).

Testing tiers + services

Script What runs Needs
npm test test/unit/** (cookies, origin, schema, memory store, email sender, core options, bearer parsing, node HTTP helpers, mountAuth options) —
npm run test:redis test/redis/** — the Lua store against a real Redis AUTH_TEST_REDIS_URL (e.g. docker run -d --name auth-am-redis -p 127.0.0.1:6380:6379 redis:7.4-alpine)
npm run test:db test/db/** — flows, sessions, schema'd and opt-in migrations, bearer sessions, verify budget, break-glass, Express OTP Postgres test DB
npm run test:fastify test/fastify/** via fastify.inject Postgres test DB
npm run test:node test/node/** — the /node adapter on a real http.Server Postgres test DB
npm run test:regression builds dist, then the Express contract, a differential transcript against the 0.2.0 baseline, Fastify parity against the 0.3.0 baseline and the operator-set compatibility of both (npm run baseline and npm run baseline -- --version 0.3.0 fetch them) Postgres test DB, registry
npm run test:types tsc --noEmit on test/types/usage.ts (every typed subpath) —
npm run test:pack npm pack → temp pnpm project without react/express → imports, default and operator migrations, tsc Postgres test DB, npmjs
npm run verify build → every tier with pass floors (unit 55 · redis 6 · db 60 · fastify 37 · node 14 · regression 38 · types 1) → 500-line file gate; prints VERIFY OK (also prepublishOnly) all of the above
npm run smoke:consumer -- --tarball the packed tarball (or --spec <pkg@ver> --anonymous, the registry package with a token-free userconfig) in a temp npm project, driving a raw node:http host through login, budget lockout and break-glass; prints CONSUMER SMOKE OK CONSUMER_SMOKE_DB_URL (required)
npm run smoke:apparel swaps this build's dist into the sibling main checkout that apparel-designer runs, records transcripts on an upgraded and a fresh database against the release that checkout holds, restores it; prints SMOKE IDENTICAL (-- --post-release after a release) APPAREL_SMOKE_DB_URL, sibling checkouts
bash scripts/ci-gate.sh the clean-clone gate: its own labelled Postgres and Redis containers, npm ci, both baselines from the registry, verify, test:pack, the consumer smoke; prints CI GATE OK Docker, corepack

Postgres: COMMERCE_TEST_DATABASE_URL (alias AUTH_TEST_DATABASE_URL; default postgres://postgres:commerce@localhost:5434/auth_manager_test; the URL must contain test). Each test file works in its own am_<file>_<pid> schema and drops it afterwards. Every tier writes TAP to test/.tap/<tier>.tap and fails on zero passes or any skipped test. The smokes write their reports to test/.smoke/. Node ≥ 22 for the tests; the runtime engines stays >=20.

Build

npm run build bundles the client (dist/auth-manager.{js,css}) and four single-file server bundles (dist/server/{index,core,fastify,node}.js) into dist.next, checks them (core, fastify and node import without express; node has no fastify import) and swaps the directory in atomically. The tarball ships dist, migrations (including migrations/operator/), types and this README.

Dependencies

Dependencies

ID Version
@oslojs/crypto ^1.0.1
@oslojs/encoding ^1.1.0
arctic ^2.3.0
cookie ^0.7.2
nodemailer ^6.9.16

Development Dependencies

ID Version
@fastify/cookie ^11.0.0
@rollup/plugin-commonjs ^28.0.0
@rollup/plugin-json ^6.1.0
@rollup/plugin-node-resolve ^15.3.0
@types/node ^22.10.0
@types/pg ^8.11.0
express ^4.21.0
fastify ^5.2.0
ioredis ^5.4.0
pg ^8.13.0
react ^19.2.5
react-dom ^19.2.5
react-router-dom ^7.1.0
rollup ^4.30.0
rollup-plugin-esbuild ^6.1.0
rollup-plugin-postcss ^4.0.2
typescript ^5.7.0

Peer Dependencies

ID Version
express >=4.18.0
fastify >=5.0.0
pg >=8.11.0
react >=18.0.0
react-dom >=18.0.0
react-router-dom >=6.0.0
Details
npm
2026-10-09 05:24:28 +00:00
8
1.3 MiB
Assets (1)
Versions (5) View all
0.4.1 2026-10-19
0.4.0 2026-10-09
0.3.0 2026-09-17
0.3.0-rc.1 2026-09-17
0.2.0 2026-09-02