UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

619 lines • 30.1 kB
"use strict"; /** * 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. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.accountKeyOf = exports.signInDoor = exports.WRONG_CREDENTIAL_SENTENCE = exports.DEFAULT_IDLE_MINUTES = exports.SignInDoorConfigError = void 0; const node_crypto_1 = require("node:crypto"); const sleep_js_1 = require("../../lib/sleep.js"); const doorGuard_js_1 = require("../doorGuard.js"); const cookie_js_1 = require("./cookie.js"); const browserGuard_js_1 = require("./browserGuard.js"); const checkGate_js_1 = require("./checkGate.js"); const errors_js_1 = require("./errors.js"); const clientAddress_js_1 = require("./clientAddress.js"); const limits_js_1 = require("./limits.js"); const audit_js_1 = require("./audit.js"); const redirectRoutes_js_1 = require("./redirectRoutes.js"); const seal_js_1 = require("./seal.js"); const source_js_1 = require("./source.js"); const types_js_1 = require("./types.js"); var errors_js_2 = require("./errors.js"); Object.defineProperty(exports, "SignInDoorConfigError", { enumerable: true, get: function () { return errors_js_2.SignInDoorConfigError; } }); 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. */ exports.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. */ exports.WRONG_CREDENTIAL_SENTENCE = 'That username and password did not sign you in.'; function signInDoor(options) { if (typeof options.production !== 'boolean') { throw new errors_js_1.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_js_1.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 ?? exports.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 errors_js_1.SignInDoorConfigError('minimumResponseMs', 'minimumResponseMs is 0 to 10 000 ms'); } const guard = (0, browserGuard_js_1.browserDoorGuard)('sign-in', options.guard, cookie.url, !cookie.secure); const limiter = (0, limits_js_1.attemptLimiter)(options.limits); const gate = (0, checkGate_js_1.checkGate)(options.checks); const trusted = (0, clientAddress_js_1.trustedProxies)(options.trustedProxies); const warn = options.warn ?? ((message) => console.warn(message)); const audit = (0, audit_js_1.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 = (0, source_js_1.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 ?? (0, seal_js_1.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 = (0, node_crypto_1.randomBytes)(32).toString('base64url'); const at = now(); const expiresAt = at + hours * 3_600_000; await options.store.create({ key: (0, cookie_js_1.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 = (0, cookie_js_1.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 = (0, clientAddress_js_1.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 errors_js_1.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: exports.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 : (0, redirectRoutes_js_1.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: (0, clientAddress_js_1.clientAddress)(req, trusted, noteForwarded), }), }); const me = async (req, res) => { const key = (0, cookie_js_1.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 = (0, cookie_js_1.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: (0, clientAddress_js_1.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: (0, clientAddress_js_1.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 ` + `'${types_js_1.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; }, }; } exports.signInDoor = signInDoor; // ─── 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 errors_js_1.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. */ function accountKeyOf(userId) { return (0, node_crypto_1.createHash)('sha256') .update(`af-account:${userId}`, 'utf8') .digest() .subarray(0, 16) .toString('base64url'); } exports.accountKeyOf = accountKeyOf; function cookieFor(publicUrl, production) { let url; try { url = new URL(publicUrl); } catch { throw new errors_js_1.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 errors_js_1.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:' || !(0, doorGuard_js_1.isLoopbackBind)(url.hostname)) { throw new errors_js_1.SignInDoorConfigError('publicUrl', `publicUrl '${publicUrl}' must be https (plain http is accepted only on this machine's ` + `loopback names, for development)`); } if (production) { throw new errors_js_1.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 ? types_js_1.SIGN_IN_COOKIE : types_js_1.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 errors_js_1.SignInDoorConfigError(name, `${name} must be more than 0 and at most ${max}`); } return value; } //# sourceMappingURL=door.js.map