agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
613 lines • 29.2 kB
JavaScript
/**
* hosting/signin/door — the sign-in door: the routes under `/auth` that start
* and end a sign-in. Nothing it handles is a turn or enters a run's record.
*
* Pattern: a plain `(req, res) → handled?` handler an app mounts in front of
* its own routes (`nodeHost({ onUnhandled })`), like the app's own
* `/auth` routes before it.
*
* GET /auth/config → `{ mode: 'password', passwordKind: 'directory' | 'local' }`
* (`passwordKind` when the checker declares its `kind`)
* GET /auth/me → `{ displayName, accountKey, expiresAt }` or 401
* POST /auth/login → the password sign-in (below)
* POST /auth/logout → end the sign-in, expire the cookie, close its sockets
*
* ── `POST /auth/login`, in order ────────────────────────────────────────────
* 1. The door guard, UNCONDITIONALLY — a login carries no credential, so a
* gate keyed on "a credential came in" would never run on it, and a forged
* cross-site login signs a victim into the attacker's account (login
* forgery). Host, Origin, and `content-type: application/json` (so a form
* cannot post it).
* 2. The body is read by THIS door, capped, and a bad one gets a fixed
* sentence: a JSON parser's own message quotes the input, and the input is
* a password.
* 3. Attempt limits (per typed name and per client address), with a growing
* delay before any refusal.
* 4. A sign-in cookie already present is ENDED first.
* 5. The password is checked. Every wrong credential gets ONE answer.
* 6. A new sign-in: 256 random bits in the cookie, only their SHA-256 in the
* store.
* Every answer waits until a minimum time has passed, so "no such user" and
* "wrong password" cannot be told apart by how fast they come back. The
* minimum is a DURATION, so it is measured on the monotonic clock
* (`performance.now()`) and waited in full (`lib/sleep`) — never on the epoch
* clock `now`, which counts whole milliseconds (an answer up to 1 ms early)
* and jumps when the system time is set (an hour ahead: an answer at once).
*
* ── The cookie (§5.4) ───────────────────────────────────────────────────────
* `__Host-Http-af-signin=…; Path=/; HttpOnly; Secure; SameSite=Strict;
* Max-Age=<lifetime>` — no `Domain`. On a plain-`http` localhost public URL
* (development only; refused in production) the browser would drop a
* `__Host-` cookie, so the name is `af-signin` and `Secure` is left off — the
* banner says so.
*
* Every `/auth` response carries `Cache-Control: no-store` and forbids framing,
* and none carries a CORS header.
*/
import { createHash, randomBytes } from 'node:crypto';
import { sleep } from '../../lib/sleep.js';
import { isLoopbackBind } from '../doorGuard.js';
import { readSignIn, signInKeyOf } from './cookie.js';
import { browserDoorGuard } from './browserGuard.js';
import { checkGate } from './checkGate.js';
import { PasswordCheckUnreachableError, SignInDoorConfigError } from './errors.js';
import { clientAddress, trustedProxies } from './clientAddress.js';
import { attemptLimiter } from './limits.js';
import { signInAuditTrail, } from './audit.js';
import { redirectRoutesFor } from './redirectRoutes.js';
import { randomSealKey } from './seal.js';
import { signInSource } from './source.js';
import { SIGN_IN_COOKIE, SIGN_IN_COOKIE_LOCALHOST, } from './types.js';
export { SignInDoorConfigError } from './errors.js';
const DEFAULT_HOURS = 8;
/** The longest a sign-in may last: a week. Browsers cap cookies at 400 days anyway. */
const MAX_HOURS = 168;
const UNAVAILABLE = 'Sign-in is unavailable. Try again shortly.';
/** A sign-in's idle limit by door mode, when `idleMinutes` is not given. */
export const DEFAULT_IDLE_MINUTES = { password: 60, redirect: 30 };
const DEFAULT_MINIMUM_RESPONSE_MS = 400;
const MAX_LOGIN_BODY_BYTES = 8 * 1024;
/** The one answer every wrong credential gets. */
export const WRONG_CREDENTIAL_SENTENCE = 'That username and password did not sign you in.';
export function signInDoor(options) {
if (typeof options.production !== 'boolean') {
throw new SignInDoorConfigError('production', 'production is true or false — the app decides');
}
const cookie = cookieFor(options.publicUrl, options.production);
const prefix = options.prefix ?? '/auth';
const now = options.now ?? Date.now;
const wait = options._sleep ?? sleep;
const hours = bounded(options.hours ?? DEFAULT_HOURS, 'hours', MAX_HOURS);
const mode = modeOf(options);
const config = configAnswerOf(mode, options.passwords);
const idleMinutes = bounded(options.idleMinutes ?? DEFAULT_IDLE_MINUTES[mode], 'idleMinutes', MAX_HOURS * 60);
const minimumMs = options.minimumResponseMs ?? DEFAULT_MINIMUM_RESPONSE_MS;
if (!Number.isFinite(minimumMs) || minimumMs < 0 || minimumMs > 10_000) {
throw new SignInDoorConfigError('minimumResponseMs', 'minimumResponseMs is 0 to 10 000 ms');
}
const guard = browserDoorGuard('sign-in', options.guard, cookie.url, !cookie.secure);
const limiter = attemptLimiter(options.limits);
const gate = checkGate(options.checks);
const trusted = trustedProxies(options.trustedProxies);
const warn = options.warn ?? ((message) => console.warn(message));
const audit = signInAuditTrail({
...(options.onAudit !== undefined && { sink: options.onAudit }),
log: options.log ?? ((line) => console.info(line)),
warn,
now,
});
// A sign-in ENDS once: two requests racing on one stale cookie (two tabs,
// a socket check beside `/auth/me`, a double-clicked sign-out) each see the
// row before either deleted it. The first end files the record; a key is
// 256 random bits and never reused, so remembering the last few is exact.
const ended = recentKeys(ENDED_KEYS_KEPT);
const signIns = signInSource({
store: options.store,
idleMinutes,
now,
onExpired: (row, why) => {
if (!ended.first(row.key))
return;
audit.file({
outcome: 'expired',
reason: why,
strategy: row.strategy,
userId: row.identity.userId,
});
},
});
const strategyName = options.passwords?.strategy ?? options.redirect?.strategy ?? 'unknown';
let warnedForwarded = false;
const noteForwarded = () => {
if (warnedForwarded)
return;
warnedForwarded = true;
warn(`[hosting] signInDoor: sign-in requests carry X-Forwarded-For but trustedProxies is not ` +
`set, so every person behind that proxy counts as ONE client address — one shared ` +
`(delay-only) address budget. Set trustedProxies to the proxy's address or range.`);
};
const identity = options.verify === undefined
? { signIn: signIns }
: { verify: options.verify, signIn: signIns };
const sealKey = options.cookieKey ?? randomSealKey();
const reply = (res, status, body, extra = {}) => {
if (res.headersSent)
return;
res.writeHead(status, { ...SECURITY_HEADERS, 'content-type': 'application/json', ...extra });
res.end(JSON.stringify(body));
};
/** A new sign-in for a proven person: 256 random bits out, only their SHA-256 kept. */
const startSignIn = async (accepted, strategy) => {
const value = randomBytes(32).toString('base64url');
const at = now();
const expiresAt = at + hours * 3_600_000;
await options.store.create({
key: signInKeyOf(value),
identity: accepted.identity,
...(accepted.displayName !== undefined && { displayName: accepted.displayName }),
strategy,
startedAt: at,
expiresAt,
lastSeenAt: at,
});
return {
setCookie: cookie.issue(value, hours * 3600),
body: {
displayName: accepted.displayName ?? accepted.identity.userId,
accountKey: accountKeyOf(accepted.identity.userId),
expiresAt: new Date(expiresAt).toISOString(),
},
};
};
/** End the sign-in a request carries, if any; resolves whether there was one. */
const endPresent = async (req) => {
const present = readSignIn(req.headers, cookie.name).key;
if (present === undefined)
return false;
await signIns.end(present);
return true;
};
const login = async (req, res) => {
// EVERY answer — refused, wrong, right, or a store that failed — waits for
// the minimum time, so no outcome can be told apart by how fast it came.
// The deadline is read off the MONOTONIC clock, never `now`: epoch ms are
// whole (a deadline taken late in one millisecond and read early in the
// next comes up to 1 ms short) and jump when the system time is set (an
// hour ahead would answer at once). `wait` runs until the clock passes it.
const deadline = performance.now() + minimumMs;
const answer = async (status, body, extra = {}) => {
await wait(deadline - performance.now());
reply(res, status, body, extra);
};
const address = clientAddress(req, trusted, noteForwarded);
const passwords = options.passwords;
// The audit record of THIS attempt: filed once, at the exit it reached,
// before the answer's wait (the record is the door's decision, not the
// socket's delivery).
let account;
const record = (outcome, reason, extra = {}) => audit.file({
outcome,
reason,
strategy: passwords.strategy,
...(account !== undefined && { account }),
...(extra.userId !== undefined && { userId: extra.userId }),
address,
...(extra.detail?.adSubCode !== undefined && { adSubCode: extra.detail.adSubCode }),
...(extra.detail?.adSubCodeName !== undefined && {
adSubCodeName: extra.detail.adSubCodeName,
}),
});
const unavailable = (reason, extra = {}) => {
record('unavailable', reason);
return answer(503, { error: UNAVAILABLE }, { 'retry-after': '5', ...extra });
};
const read = await readLoginBody(req);
if (read.kind === 'refused') {
if (read.username !== undefined)
account = budgetKeyOf(passwords, read.username);
record('refused', 'malformed-request');
return answer(read.status, { error: read.sentence });
}
// Counted as it STARTS, before the slow check (review idI34 B-1), under the
// ACCOUNT the checker says the name reaches — `alice`, `ALICE` and
// `alice@corp.example` are one budget and one check in flight (idI57 B-1).
account = budgetKeyOf(passwords, read.username);
const verdict = limiter.begin(account, address, now());
if (verdict.kind === 'refuse') {
record('limited', verdict.why);
return answer(429, { error: 'Too many sign-in attempts. Wait, then try again.' }, { 'retry-after': String(verdict.retryAfterSeconds) });
}
if (verdict.kind === 'busy')
return unavailable('limits-full');
const { ticket } = verdict;
await wait(verdict.delayMs);
const expire = {};
let accepted;
// What the checker said about a refusal — the first note only.
let detail;
const note = (said) => {
detail ??= checkDetailOf(said);
};
try {
if (await endPresent(req))
expire['set-cookie'] = cookie.expired();
const ran = await gate.run(() => passwords.check(read.username, read.password, note));
if (ran === undefined) {
limiter.abandoned(ticket);
return await unavailable('checks-busy', expire);
}
accepted = ran.value;
}
catch (error) {
// Un-counted ONLY when the password never left this process; a check
// that failed after it was sent (a bind that timed out) may have been
// charged by the directory, so it stays counted (review idI57 S-6).
if (error instanceof PasswordCheckUnreachableError) {
limiter.abandoned(ticket);
return unavailable('backend-unreachable', expire);
}
limiter.failed(ticket, now());
return unavailable('check-failed', expire);
}
if (accepted === undefined) {
limiter.failed(ticket, now());
record('refused', detail?.reason ?? 'wrong-credential', {
...(detail !== undefined && { detail }),
});
return answer(401, { error: WRONG_CREDENTIAL_SENTENCE }, expire);
}
limiter.succeeded(ticket);
try {
const signedIn = await startSignIn(accepted, passwords.strategy);
record('signed-in', 'accepted', { userId: accepted.identity.userId });
return await answer(200, signedIn.body, { 'set-cookie': signedIn.setCookie });
}
catch {
// A store that is full or down: the same 503, after the same minimum
// time — never an oracle for "that password was right".
return unavailable('store-failed', expire);
}
};
const redirectRoutes = options.redirect === undefined
? undefined
: redirectRoutesFor({
strategy: options.redirect,
publicUrl: cookie.url,
prefix,
cookie,
sealKey,
now,
warn,
endPresent,
startSignIn,
audit: (req, outcome, reason, userId) => audit.file({
outcome,
reason,
strategy: strategyName,
...(userId !== undefined && { userId }),
address: clientAddress(req, trusted, noteForwarded),
}),
});
const me = async (req, res) => {
const key = readSignIn(req.headers, cookie.name).key;
const who = key === undefined ? undefined : await signIns.identify(key);
const row = key === undefined || who === undefined ? undefined : await options.store.find(key);
if (row === undefined)
return reply(res, 401, { error: 'Not signed in.' });
return reply(res, 200, {
displayName: row.displayName ?? row.identity.userId,
accountKey: accountKeyOf(row.identity.userId),
expiresAt: new Date(row.expiresAt).toISOString(),
});
};
const logout = async (req, res) => {
// Only a LIVE sign-in's end is a sign-out worth recording; a stale cookie
// ends nothing anybody could still use.
const key = readSignIn(req.headers, cookie.name).key;
// A store that cannot answer the lookup changes nothing about the logout.
const live = key === undefined ? undefined : await options.store.find(key).catch(() => undefined);
await endPresent(req);
const at = now();
const stillLive = live !== undefined && at < live.expiresAt && at - live.lastSeenAt < idleMinutes * 60_000;
if (live !== undefined && stillLive && ended.first(live.key)) {
audit.file({
outcome: 'signed-out',
reason: 'logout',
strategy: live.strategy,
userId: live.identity.userId,
address: clientAddress(req, trusted, noteForwarded),
});
}
const next = (await redirectRoutes?.logoutNext()) ?? '/';
return reply(res, 200, { next }, { 'set-cookie': cookie.expired() });
};
const route = async (req, res, path) => {
const refusal = guard.check(req);
if (refusal !== undefined) {
// A refused LOGIN is a sign-in attempt (a forged cross-site login is
// the shape this guard exists for); a refused `/auth/me` is not.
if (path === 'login' || path === 'callback') {
audit.file({
outcome: 'refused',
reason: 'cross-site',
strategy: strategyName,
address: clientAddress(req, trusted, noteForwarded),
});
}
return reply(res, refusal.status, { error: refusal.message, code: refusal.code });
}
const method = (req.method ?? 'GET').toUpperCase();
const routes = ROUTES[mode];
const want = Object.prototype.hasOwnProperty.call(routes, path) ? routes[path] : undefined;
if (want === undefined)
return reply(res, 404, { error: 'No such sign-in route.' });
if (method !== want) {
return reply(res, 405, { error: `Use ${want}.` }, { allow: want });
}
if (path === 'config')
return reply(res, 200, config);
if (path === 'me')
return me(req, res);
if (path === 'logout')
return logout(req, res);
if (redirectRoutes !== undefined) {
if (path === 'login')
return redirectRoutes.login(req, res);
return redirectRoutes.callback(req, res);
}
return login(req, res);
};
return {
signIns,
cookieName: cookie.name,
identity,
hostSignIn: { cookieName: cookie.name, identity },
banner: [
`identity: sign-in door ${prefix}/{config,me,login,logout}; cookie ${cookie.name}` +
(cookie.secure ? ' (Secure, HttpOnly, SameSite=Strict)' : ''),
...(cookie.secure
? []
: [
`identity: WARNING ${cookie.url.origin} is plain http on this machine: the cookie is ` +
`'${SIGN_IN_COOKIE_LOCALHOST}' without Secure or the __Host- prefix (development only; production refuses it). ` +
`Cookies do not isolate by port, so any other app on this machine's loopback names can set this door's cookies`,
]),
`identity: sign-ins last ${hours} h, ${idleMinutes} min idle`,
...(mode === 'redirect'
? [
`identity: browser sign-in by redirect (${prefix}/login → the IdP → ${prefix}/callback); PKCE ${options.redirect?.pkce === true ? 'on' : 'OFF (AD FS 2016)'} — PENDING INDEPENDENT REVIEW before a company install`,
options.cookieKey === undefined
? 'identity: WARNING no IDENTITY_COOKIE_KEY_FILE: sign-ins in progress are sealed with a per-process key, so a restart or another replica breaks them'
: 'identity: sign-ins in progress are sealed with IDENTITY_COOKIE_KEY_FILE',
]
: []),
'identity: sign-ins and attempt budgets are per process. Run one replica, or pin each ' +
'browser to one replica (sticky sessions), or configure a shared sign-in store.',
],
async handle(req, res) {
const path = (req.url ?? '').split('?')[0] ?? '';
if (path !== prefix && !path.startsWith(`${prefix}/`))
return false;
try {
await route(req, res, path.slice(prefix.length + 1));
}
catch {
// A store that threw, a socket that went away: this request's failure,
// never the process's — and never the error's own words.
reply(res, 503, { error: 'Sign-in is unavailable. Try again shortly.' });
}
return true;
},
};
}
// ─── Pieces ──────────────────────────────────────────────────────────
/** How many ended sign-in keys the door remembers, to file each end once. */
const ENDED_KEYS_KEPT = 1024;
/** A bounded set of recently seen keys: `first(key)` is true only the first time. */
function recentKeys(max) {
const seen = new Set();
return {
first(key) {
if (seen.has(key))
return false;
seen.add(key);
if (seen.size > max)
seen.delete(seen.values().next().value);
return true;
},
};
}
const ROUTES = {
password: { config: 'GET', me: 'GET', login: 'POST', logout: 'POST' },
redirect: { config: 'GET', me: 'GET', login: 'GET', callback: 'GET', logout: 'POST' },
};
function modeOf(options) {
const both = options.passwords !== undefined && options.redirect !== undefined;
const neither = options.passwords === undefined && options.redirect === undefined;
if (both || neither) {
throw new TypeError(`[hosting] signInDoor takes exactly one strategy: 'passwords' (a password form) or ` +
`'redirect' (an identity provider's sign-in page).`);
}
return options.passwords !== undefined ? 'password' : 'redirect';
}
/** What `GET /auth/config` may say a password door takes. */
const PASSWORD_KINDS = ['directory', 'local'];
/**
* `GET /auth/config`'s answer, fixed at construction: the mode, and on a
* password door the checker's declared `kind` as `passwordKind` — so a page
* can label its form. A checker that declares none adds nothing (never a
* guess); one that declares a word outside the vocabulary is refused here,
* because the page branches on it.
*/
function configAnswerOf(mode, passwords) {
const kind = passwords?.kind;
if (mode !== 'password' || kind === undefined)
return { mode };
if (!PASSWORD_KINDS.includes(kind)) {
throw new SignInDoorConfigError('passwords', `passwords.kind is 'directory' or 'local' (got ${JSON.stringify(kind)})`);
}
return { mode, passwordKind: kind };
}
const SECURITY_HEADERS = {
'cache-control': 'no-store',
'content-security-policy': "frame-ancestors 'none'",
'x-frame-options': 'DENY',
'referrer-policy': 'no-referrer',
'x-content-type-options': 'nosniff',
};
/** A 128-bit key for the page's caches: one per person, not the id itself. */
export function accountKeyOf(userId) {
return createHash('sha256')
.update(`af-account:${userId}`, 'utf8')
.digest()
.subarray(0, 16)
.toString('base64url');
}
function cookieFor(publicUrl, production) {
let url;
try {
url = new URL(publicUrl);
}
catch {
throw new SignInDoorConfigError('publicUrl', `publicUrl '${publicUrl}' is not an absolute URL`);
}
// The door's routes, its cookies (`Path=/`, `__Host-`) and the callback URL
// all live at the origin's root, so an app published under a path would
// send the IdP a redirect URI it never registered (review idI57 N-5).
if (url.pathname !== '/' || url.search !== '' || url.hash !== '') {
throw new SignInDoorConfigError('publicUrl', `publicUrl '${publicUrl}' has a path; the sign-in door runs at the origin's root, so give ` +
`the origin alone (${url.origin})`);
}
const secure = url.protocol === 'https:';
if (!secure) {
if (url.protocol !== 'http:' || !isLoopbackBind(url.hostname)) {
throw new SignInDoorConfigError('publicUrl', `publicUrl '${publicUrl}' must be https (plain http is accepted only on this machine's ` +
`loopback names, for development)`);
}
if (production) {
throw new SignInDoorConfigError('publicUrl', `publicUrl '${publicUrl}' is plain http, and this is production. Without https the ` +
`browser drops the Secure, __Host- sign-in cookie's protections`);
}
}
const name = secure ? SIGN_IN_COOKIE : SIGN_IN_COOKIE_LOCALHOST;
const attrs = `Path=/; HttpOnly;${secure ? ' Secure;' : ''} SameSite=Strict`;
return {
name,
secure,
url,
issue: (value, maxAge) => `${name}=${value}; ${attrs}; Max-Age=${Math.floor(maxAge)}`,
expired: () => `${name}=; ${attrs}; Max-Age=0`,
txName: (attempt) => `${name}-tx-${attempt}`,
issueTx: (attempt, sealed) => `${name}-tx-${attempt}=${sealed}; Path=/; HttpOnly;${secure ? ' Secure;' : ''} SameSite=Lax; Max-Age=600`,
expireTx: (attempt) => `${name}-tx-${attempt}=; Path=/; HttpOnly;${secure ? ' Secure;' : ''} SameSite=Lax; Max-Age=0`,
};
}
const BAD_BODY = 'The sign-in request must be a JSON object: { "username": "…", "password": "…" }.';
/** Read `{ username, password }`. Never quotes the body in any answer. */
async function readLoginBody(req) {
const declared = Number(req.headers['content-length'] ?? '0');
if (declared > MAX_LOGIN_BODY_BYTES) {
req.resume();
return { kind: 'refused', status: 413, sentence: 'The sign-in request is too large.' };
}
const chunks = [];
let bytes = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
bytes += buffer.length;
if (bytes > MAX_LOGIN_BODY_BYTES) {
return { kind: 'refused', status: 413, sentence: 'The sign-in request is too large.' };
}
chunks.push(buffer);
}
let body;
try {
body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
}
catch {
return { kind: 'refused', status: 400, sentence: BAD_BODY };
}
const { username, password } = (body ?? {});
if (typeof username !== 'string' || typeof password !== 'string') {
return { kind: 'refused', status: 400, sentence: BAD_BODY };
}
// One spelling per name: `José` typed as NFC and as NFD is one person.
const name = username.trim().normalize('NFC');
// An empty password is refused before any check (RFC 4513 §6.3.1's advice,
// kept for every password strategy): it is a malformed request, not a guess.
// The name an audit record may carry: a plain one, never one that could
// forge a log field or flood the line.
const named = name.length > 0 && name.length <= 256 && !CONTROL_CHARACTER.test(name)
? { username: name }
: {};
if (name.length === 0 || password.length === 0) {
return {
kind: 'refused',
status: 400,
sentence: 'A username and a password are both required.',
...named,
};
}
if (name.length > 256 || password.length > 1024) {
return { kind: 'refused', status: 400, sentence: BAD_BODY, ...named };
}
// A control character is never part of a password a person typed, and a
// directory may cut the password at one (Samba signs `right\0junk` in —
// review idI57 N-7). Refused as malformed, before any check.
if (CONTROL_CHARACTER.test(password) || CONTROL_CHARACTER.test(name)) {
return { kind: 'refused', status: 400, sentence: BAD_BODY, ...named };
}
return { kind: 'ok', username: name, password };
}
const CONTROL_CHARACTER = /\p{Cc}/u;
/**
* A checker's note, copied down to the fields the record may carry — a
* checker is app code, and only these classes travel onward (never a field a
* checker added, which could be anything).
*/
function checkDetailOf(said) {
// Each field is READ ONCE, then tested and kept as that one value: a getter
// (or a Proxy) answering a vocabulary word to the test and free text to a
// second read must not carry the free text past the test.
const saidReason = said?.reason;
const saidCode = said?.adSubCode;
const saidName = said?.adSubCodeName;
const reason = REFUSAL_REASONS.find((known) => known === saidReason) ?? 'wrong-credential';
const code = typeof saidCode === 'string' && /^[0-9a-f]{1,8}$/i.test(saidCode)
? saidCode.toLowerCase()
: undefined;
const name = AD_SUB_CODE_NAMES.find((known) => known === saidName);
return {
reason,
...(code !== undefined && { adSubCode: code }),
...(name !== undefined && { adSubCodeName: name }),
};
}
const REFUSAL_REASONS = [
'wrong-credential',
'unknown-account',
'name-refused',
'malformed-request',
'account-unresolved',
'not-in-group',
];
const AD_SUB_CODE_NAMES = [
'bad-password',
'locked',
'password-expired',
'must-change',
'disabled',
'account-expired',
'unknown',
];
/** The key a typed name's attempts are counted under — the checker's, when it names one. */
function budgetKeyOf(passwords, typed) {
return passwords.budgetKey?.(typed) ?? typed;
}
function bounded(value, name, max) {
if (!Number.isFinite(value) || value <= 0 || value > max) {
throw new SignInDoorConfigError(name, `${name} must be more than 0 and at most ${max}`);
}
return value;
}
//# sourceMappingURL=door.js.map