agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
108 lines • 6.11 kB
TypeScript
/**
* hosting/signin/limits — attempt limits on a password door, correct under
* concurrency, bounded, and per process.
*
* ── Why the order matters (review idI34 B-1) ────────────────────────────────
* An attempt is COUNTED WHEN IT STARTS (`begin`), before the slow password
* check — never after it. Counting after let forty parallel guesses all read
* the same stale counter and all reach the checker. With `directory-password`
* that burst would walk past this budget into Active Directory's own lockout
* threshold and lock the real account company-wide; counting at the start is
* what makes a budget "half of AD's threshold" mean anything.
*
* Three rules, then:
* 1. **Per typed name: a hard budget.** `perName` attempts per window; past it
* the name is refused (429, `Retry-After`) until the window passes. A delay
* grows with the attempts already counted BEFORE any refusal, because a hard
* refusal is itself a lockout anyone can trigger for a colleague by typing
* their name (the documented trade: that is the price of a name budget).
* 2. **One check in flight per name.** A second attempt for a name whose check
* has not answered is refused at once (429) — parallelism buys nothing.
* 3. **Per client address: a growing DELAY only, never a refusal.** Behind a
* proxy or a shared NAT every person can arrive from one address; a hard
* address budget would let anybody lock the whole company out of sign-in
* with a handful of wrong guesses. The per-name budget is what protects an
* account; the address delay slows one client spraying many names. IPv6
* clients are counted per /64 (one customer's network), so rotating
* addresses inside it does not escape the delay.
* **Opt-in hard refusal (`refuseAddressAfter`):** an install whose clients
* each arrive from their OWN address (no shared NAT or proxy in front, or
* `trustedProxies` set so each person's address is resolved) can ask for a
* refusal too: once an address holds that many failed attempts inside the
* window, it is refused (429) without a check until a full window passes
* since its last counted attempt. Refused attempts are not counted, so the
* refusal is bounded by one window. Off by default, for the NAT reason
* above.
*
* ── Bounded without being flushable ─────────────────────────────────────────
* Name and address counters live in two SEPARATE bounded maps, so address churn
* can never push a name counter out. When a map is full, the least recently used
* counter that is NOT penalising (none in flight, and not delaying or refusing)
* is dropped; a counter that still penalises is never dropped. If every counter
* in the map is penalising, a NEW name or address is refused as `busy` (503) —
* failing closed rather than forgetting a penalty.
*
* What it does not claim: it REDUCES guessing and lockout risk and never
* claims to prevent lockout; the counters are per process (N replicas give
* N budgets — the banner says so).
*/
export interface AttemptLimits {
/** Attempts allowed per typed name per window before refusal. Default 5. */
readonly perName?: number;
/** Attempts per client address per window after which the address's delay is at its cap. Default 20. */
readonly perAddress?: number;
/**
* OPT-IN: failed attempts per client address per window after which the
* address is REFUSED (429), not only delayed. Unset (the default): never
* refused — behind a shared NAT or proxy every person is one address, and a
* hard address budget lets anybody lock everybody out. Set it only when each
* person's own address reaches the door (`trustedProxies` set behind a
* proxy). A right password never counts against an address.
*/
readonly refuseAddressAfter?: number;
/** The window, in minutes. Default 15. */
readonly windowMinutes?: number;
/** The first back-off step, in ms; each further attempt doubles it (capped at 8×). Default 1000. */
readonly backoffMs?: number;
/** The most counters kept per map (names, addresses). Default 10 000. */
readonly maxEntries?: number;
}
/** A counted attempt, to be settled exactly once. */
export interface AttemptTicket {
readonly name: string;
readonly address: string;
}
/** What the limiter says about an attempt it has just COUNTED (or refused to count). */
export type AttemptVerdict = {
readonly kind: 'allow';
readonly delayMs: number;
readonly ticket: AttemptTicket;
} | {
readonly kind: 'refuse';
readonly retryAfterSeconds: number;
/** Which rule refused: the name's budget, a check in flight for it, or the opt-in address budget. */
readonly why: 'name-budget' | 'name-in-flight' | 'address-budget';
} | {
readonly kind: 'busy';
readonly retryAfterSeconds: number;
};
export interface AttemptLimiter {
/** Count an attempt as it starts. `allow` carries a ticket that MUST be settled. */
begin(name: string, address: string, now: number): AttemptVerdict;
/** The credential was wrong: the attempt stays counted. */
failed(ticket: AttemptTicket, now: number): void;
/** The credential was right: the name's counter is cleared, and the address is not charged. */
succeeded(ticket: AttemptTicket): void;
/** The check could not run (the directory is down): the attempt is un-counted. */
abandoned(ticket: AttemptTicket): void;
/** How many counters are kept, both maps — for a test or a banner. */
readonly size: number;
}
export declare function attemptLimiter(limits?: AttemptLimits): AttemptLimiter;
/**
* The address a budget is kept for: an IPv4 address as is; an IPv6 address by
* its /64 (the first four groups) — one network, however many addresses it
* rotates through.
*/
export declare function addressBucket(address: string): string;
//# sourceMappingURL=limits.d.ts.map