agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
169 lines • 9.6 kB
TypeScript
/**
* hosting/signin/audit — the sign-in door's audit trail: one record per
* sign-in outcome, handed to a sink the app owns AND written as one log line.
*
* const door = signInDoor({
* passwords, store, publicUrl, production,
* onAudit: (record) => securityLog.write(record), // typed, optional
* // log: (line) => logger.info(line), // default: console.info
* });
*
* ── Why a door option and not a typed event ─────────────────────────────────
* Every typed event carries `EventMeta`, whose `runId` and `runtimeStageId`
* are REQUIRED, and is emitted by one agent's dispatcher. A sign-in is never a
* run: no agent exists when a person types their password. The library's own
* answer for door facts that happen before any run is a SINK at the door —
* `standingAgent({ onIngressDecision })` (ingressRecord.ts) — and this is the
* same shape, owned by the one place every sign-in outcome is decided.
*
* ── What a record carries, and what it never does ───────────────────────────
* Classes and identifiers only: the outcome, a reason class, the strategy, the
* ACCOUNT the typed name reaches (the checker's `budgetKey` — `alice`,
* `CORP\alice` and `alice@corp.example` are all `alice`), the id the strategy
* PROVED, the client address after trusted-proxy resolution, the time, and on
* `directory-password` the AD sub-code with its name. Never the password,
* never a cookie or its key, never a token, never an error's message.
*
* ── It is not the audit hash chain ──────────────────────────────────────────
* Like the ingress record it is a STREAM into your own sink, not hashed, not
* sequenced and not verifiable by `verifyAuditBundle`. Chain it in your own
* store if you need tamper evidence.
*/
/**
* How a sign-in ended. The first four are attempts at `/auth/login` (and a
* redirect callback); the last two are a sign-in ENDING.
*
* - `'signed-in'` — a sign-in was created (200 / the callback's 303).
* - `'refused'` — the credential or the request was refused (401, 400, the
* door guard, a redirect callback that failed on the browser's side).
* - `'limited'` — an attempt limit refused it before any check (429). The
* directory was NOT contacted.
* - `'unavailable'` — the door could not answer (503): the directory or the
* identity provider unreachable, the checks full, the store down.
* - `'signed-out'` — `POST /auth/logout` ended a live sign-in.
* - `'expired'` — a sign-in was found past its lifetime or idle limit and
* ended. Recorded when it is FOUND (the next request carrying it), not at
* the instant it lapsed; a sign-in never presented again is not recorded.
* A sign-in's END is filed once, however many requests race on it.
*/
export type SignInOutcome = 'signed-in' | 'refused' | 'limited' | 'unavailable' | 'signed-out' | 'expired';
/**
* Why a password check refused a credential — what a {@link PasswordChecker}
* may say through its `note` (types.ts). The person never sees it; it travels
* to the operator's record only.
*
* - `'wrong-credential'` — the backend refused the name and password
* (`directory-password`: the bind; the AD sub-code says more).
* - `'unknown-account'` — no such name (`local-password`; a directory cannot
* tell this apart from a wrong password without a service account).
* - `'name-refused'` — the typed name never reached the backend (it fails the
* name rule, or names another domain).
* - `'malformed-request'` — an empty password or a control character.
* - `'account-unresolved'` — the bind worked but Who-am-I did not name an
* account of this domain, or not EXACTLY one entry matched it.
* - `'not-in-group'` — the person is not in the required group.
*/
export type PasswordRefusalReason = 'wrong-credential' | 'unknown-account' | 'name-refused' | 'malformed-request' | 'account-unresolved' | 'not-in-group';
/**
* The reason class beside an {@link SignInOutcome} — the field a dashboard
* groups by. Every value belongs to one outcome:
*
* - `'signed-in'`: `'accepted'`.
* - `'refused'`: every {@link PasswordRefusalReason}; `'cross-site'` (the door
* guard refused a login: a foreign Origin, a Host not configured, a form
* post); and a redirect callback's `RedirectFailure` codes —
* `'state-mismatch'`, `'idp-error'`, `'exchange-failed'`,
* `'id-token-refused'`, `'not-a-person'`.
* - `'limited'`: `'name-budget'` (the account's attempt budget is spent),
* `'name-in-flight'` (a check for the account is still running),
* `'address-budget'` (the opt-in hard per-address budget is spent).
* - `'unavailable'`: `'backend-unreachable'` (the password never left this
* process), `'check-failed'` (it failed after it was sent — counted),
* `'checks-busy'` (the door-wide check cap is full), `'limits-full'` (the
* attempt counters are full of penalties), `'store-failed'`,
* `'idp-unavailable'`.
* - `'signed-out'`: `'logout'`. `'expired'`: `'lifetime'`, `'idle'`.
*/
export type SignInReason = 'accepted' | PasswordRefusalReason | 'cross-site' | 'state-mismatch' | 'idp-error' | 'exchange-failed' | 'id-token-refused' | 'not-a-person' | 'name-budget' | 'name-in-flight' | 'address-budget' | 'backend-unreachable' | 'check-failed' | 'checks-busy' | 'limits-full' | 'store-failed' | 'idp-unavailable' | 'logout' | 'lifetime' | 'idle';
/**
* What an Active Directory bind refusal's sub-code (`data 52e` in the LDAP
* error) means, as a word. Anything not in the list — `525` (no such user),
* `530`/`531` (logon hours, workstation), a code a later AD invents — is
* `'unknown'`, and the raw code still travels beside it.
*
* AD reports most of these only when the password was RIGHT (an expired or
* disabled account with a wrong password is `52e`); `775` (locked) comes back
* whatever the password.
*/
export type AdSubCodeName = 'bad-password' | 'locked' | 'password-expired' | 'must-change' | 'disabled' | 'account-expired' | 'unknown';
/** What a password check may tell the door about a refusal — never a secret. */
export interface PasswordCheckDetail {
readonly reason: PasswordRefusalReason;
/** `directory-password`: AD's bind sub-code, lower-case hex (`'52e'`). */
readonly adSubCode?: string;
/** `directory-password`: what that sub-code means. */
readonly adSubCodeName?: AdSubCodeName;
}
/**
* One sign-in outcome, as plain data. Every field is a class of what happened
* or an identifier the operator needs; nothing here is a secret and nothing is
* a message.
*/
export interface SignInAuditRecord {
/** When the door decided, epoch ms (the door's `now`). */
readonly at: number;
readonly outcome: SignInOutcome;
readonly reason: SignInReason;
/** Which strategy: `directory-password`, `local-password`, `oidc-token`, … */
readonly strategy: string;
/**
* The ACCOUNT the typed name reaches — the checker's `budgetKey`
* (`directory-password`: lower-cased, any `DOMAIN\` and `@domain` removed,
* so `user`, `DOMAIN\user` and `user@dns.domain` are all the
* sAMAccountName). Absent when no name was read (a redirect sign-in, a body
* that did not parse, a request the door guard refused).
*/
readonly account?: string;
/** The id the strategy PROVED — on `signed-in`, `signed-out` and `expired` only. */
readonly userId?: string;
/**
* The client address, after trusted-proxy resolution (the rightmost
* `X-Forwarded-For` hop that is not a trusted proxy; the socket's address
* otherwise). Absent on `expired`, which no request of that person caused.
*/
readonly address?: string;
/** `directory-password`: AD's bind sub-code (`'52e'`), when the bind was refused with one. */
readonly adSubCode?: string;
/** `directory-password`: what the sub-code means (`'bad-password'`). */
readonly adSubCodeName?: AdSubCodeName;
}
/**
* Where records go — `signInDoor({ onAudit })`. Called synchronously, once per
* outcome. A sink that throws is contained (a broken log must not turn a
* sign-in into a failure) and reported once through the door's `warn`. Do the
* slow part yourself: buffer, and never `await` the network inside it.
*/
export type SignInAuditSink = (record: SignInAuditRecord) => void;
/** The prefix of the door's audit log line; the rest is the record as JSON. */
export declare const SIGN_IN_AUDIT_PREFIX = "[identity] sign-in";
/**
* The record as ONE log line: the prefix, then a JSON object with `time` (ISO
* 8601, UTC) in place of `at`. JSON so a name holding a quote or a space can
* never forge a second field, and so a log pipeline parses it without a
* grammar of its own.
*/
export declare function signInAuditLine(record: SignInAuditRecord): string;
/**
* The door's bookkeeping: build a record from what it decided, send it to the
* sink and the log, contain both. @internal — the option is public, this is not.
*/
export interface SignInAuditTrail {
file(record: Omit<SignInAuditRecord, 'at'>): void;
}
export declare function signInAuditTrail(options: {
readonly sink?: SignInAuditSink;
readonly log: (line: string) => void;
readonly warn: (message: string) => void;
readonly now: () => number;
}): SignInAuditTrail;
//# sourceMappingURL=audit.d.ts.map