export interface AuthUser { id: string; email: string; name: string; role: R; emailVerified: boolean; /** * Whether TOTP two-factor authentication is active on the account. A public * account-status flag in the same spirit as `emailVerified` — it drives the * `TwoFactorManager` UI and the login two-step, and never carries the secret * itself. `false` for every user until they finish 2FA setup, and for * consumers who don't wire 2FA at all (the adapter defaults a missing column * to `false`). */ totpEnabled: boolean; } export interface AuthSession { userId: string; email: string; role: R; tokenVersion: number; } export interface PasswordConfig { minLength?: number; requireUppercase?: boolean; requireLowercase?: boolean; requireDigit?: boolean; /** * Require at least one character outside `A-Za-z0-9`. The client checklist * has offered this rule since v8; the server could not enforce it, so a UI * demanding it accepted nothing the server refused. Off by default, like the * other character classes. */ requireSpecial?: boolean; /** * PBKDF2-HMAC-SHA256 work factor for new password hashes. Default `600000` * (current OWASP recommendation). Verification always reads the iteration * count from the stored hash, so changing this only affects newly created * hashes — existing ones are transparently upgraded on the owner's next * login (`needsRehash`). Raise for higher assurance; lowering below the * OWASP minimum weakens brute-force resistance and is warned about in a * production config (see `createAuthDeps`). */ pbkdf2Iterations?: number; } /** * Private P-256 signing key for `jwt.algorithm: 'ES256'`, as a JWK — the shape * `crypto.subtle.exportKey('jwk', …)` produces, extended with the RFC 7517 * `kid` member that TypeScript's built-in `JsonWebKey` type omits. Generate * one with `generateES256KeyPair()` (server export), which also stamps the * RFC 7638 thumbprint `kid` into the key. Contains the private scalar `d` — * treat it like the HMAC secret (secret manager, never logged); the JWKS * endpoint (`createJWKSHandler`) publishes only the public members. */ export interface Es256PrivateJwk extends JsonWebKey { kid?: string; } /** * Public P-256 verification key as a JWK with a mandatory RFC 7517 `kid`, * used in `JwtConfig.previousPublicKeys` and returned by * `generateES256KeyPair()`. The `kid` is what token verification and the JWKS * endpoint (`createJWKSHandler`) select keys by — for keys generated by this * package it is the deterministic RFC 7638 SHA-256 thumbprint. */ export interface Es256PublicJwk extends JsonWebKey { kid: string; } export interface JwtConfig { /** * Primary secret used for signing new session tokens under HS256 — and, * under **every** algorithm, the package-internal short-lived signed tokens * (`createSignedToken`/`verifySignedToken`, e.g. the pending-2FA handle). * Those deliberately stay HMAC-based even with `algorithm: 'ES256'`: they * never leave this deployment, so asymmetric verification buys nothing. * `secret` therefore remains required in ES256 mode. */ secret: string; /** * Signature algorithm for the session JWT. Default `'HS256'` (HMAC-SHA256 * over `secret`) — the existing behaviour, byte-identical for current * consumers. `'ES256'` (ECDSA P-256; requires `signingKey`) makes this * deployment an identity provider whose tokens other services can verify * with the public key alone — publish it via `createJWKSHandler`. The token * proves identity only ("who you are"); consuming services decide access * themselves. Verification always pins THIS configured algorithm: the token * header's `alg` is checked against it and never trusted on its own, so an * HS256-signed token can never verify under an ES256 config (or vice versa). */ algorithm?: 'HS256' | 'ES256'; /** * Private P-256 signing key (JWK) — required for (and only used by) * `algorithm: 'ES256'`; setting it without that algorithm is warned about * loudly. Generate with `generateES256KeyPair()`. The active key's `kid` is * `keyId` when set, else the JWK's own `kid` (stamped by * `generateES256KeyPair`), else the RFC 7638 thumbprint computed on the fly. * Only its public members are ever served by `createJWKSHandler`. */ signingKey?: Es256PrivateJwk; /** * Secure attribute for the session cookie. Default `true`; set to `false` * for non-HTTPS development servers or E2E harnesses. Production deployments * should always keep this `true` (or omit it). * * **Package-wide.** An explicit `false` here — or on `csrf.cookieSecure` or * `refreshToken.cookieSecure` — declares the whole deployment non-HTTPS, and * that answer drives five things: HSTS is not emitted, the production * brute-force warnings fall silent, and the 2FA and passkey-ceremony cookies * drop both their `__Host-` prefix and their `Secure` flag (a browser * discards a `Secure` cookie over plain HTTP, and the flows that lose one * report a challenge-store failure, never a cookie problem). Set it on all * three or on none — `createAuthDeps` warns when they disagree. */ cookieSecure?: boolean; /** Override the SameSite attribute for the session cookie. Default `'lax'`. */ cookieSameSite?: 'lax' | 'strict' | 'none'; /** * Optional identifier written into the token's `kid` header. Required when * `previousSecrets` is set so the verifier can disambiguate old vs new * tokens during key rotation. Safe to leave empty for the single-secret * case — tokens are emitted without a `kid` claim and every secret is * tried on verify. Under `algorithm: 'ES256'` this is the `kid` of the * active signing key; when omitted, the RFC 7638 thumbprint of `signingKey` * is used instead (ES256 tokens always carry a `kid`). */ keyId?: string; /** * Older secrets accepted for verification only. Enables key rotation: * deploy the new `secret` + `keyId` and move the previous values here so * existing sessions keep working until they expire. Entries are tried in * order; remove a secret from this list to permanently invalidate any * session still signed by it. */ previousSecrets?: Array<{ secret: string; keyId?: string; }>; /** * ES256 counterpart of `previousSecrets`: retired **public** keys accepted * for verification only, so sessions signed by a previous key keep working * through the rotation window. Each entry carries its `kid`; a token whose * `kid` matches no configured key is rejected (fail-closed, mirroring the * `previousSecrets` semantics — no silent fallback to unrelated keys). * Remove an entry to permanently invalidate tokens still signed by it. * Entries are also published by `createJWKSHandler` so consuming services * share the same rotation window. Public members only — never place the * private JWK here. */ previousPublicKeys?: Es256PublicJwk[]; /** * Domain attribute for the session cookie, e.g. `'.example.com'` to share * the IdP's session across sibling apps (`auth.example.com` sets, every * `*.example.com` app receives it). Applied **symmetrically** by * `setSessionCookie` and `clearSessionCookie` — a delete without the * matching Domain targets a different cookie and would leave the session * alive after logout. Deliberately scoped to the session cookie only: the * refresh-token and CSRF cookies stay host-scoped, because rotation and * CSRF issuance are IdP-internal — consuming apps only *verify* the session * JWT (via `createJWKSHandler`), they never rotate. Incompatible with a * `__Host-`-prefixed `cookieName` (browsers reject that combination; * guarded with a thrown error). Omit for a host-scoped cookie (default). */ cookieDomain?: string; expiresIn?: string; cookieName?: string; } export interface LockoutConfig { /** Failed attempts that lock the account. @default 5 */ maxAttempts?: number; /** How long the lock holds, in minutes. @default 15 */ durationMinutes?: number; /** * How long a failed attempt keeps counting, in minutes. @default 60 * * The counter is otherwise cleared only by a successful sign-in, so without a * decay window `maxAttempts` typos spread over any timespan at all add up to * a lockout. * * **Keep it well above `durationMinutes`.** The window doubles as the budget * an attacker gets for waiting: 60 minutes allows 5 guesses/h at the defaults, * 15 allows 20 (simulated in `login.test.ts`; the derivation is in AUTH.md → * Known Limitations). **`0` does not mean "no decay"** — it would reset the * counter on every attempt and switch the lockout off, so any value that is * not a positive finite number throws when the login handler is created. Run * without a lockout via `lockout: null`, not through this field. * * Read by `createLoginHandler`, not by the repository: `recordFailedLogin` * receives the resolved lock (`FailedLoginLock`), never this config, and has * no decay duty. */ decayMinutes?: number; } /** * Lifetimes of the three single-use tokens the package mails out. Each accepts * the `Ns | Nm | Nh | Nd` grammar of every other duration field (`jwt.expiresIn`, * `refreshToken.*`, `twoFactor.pendingTokenTtl`) and is resolved when the * handler is created, so a malformed value throws at wiring time rather than in * a request. * * The window is enforced by the repository's `consume*` claim, which refuses a * token whose stored expiry has passed — shortening a value here does not * shorten the life of a token already mailed out. */ export interface TokenTtlConfig { /** * Email-verification link from `createRegisterHandler`. @default '24h' * * The longest of the three on purpose: register signs the user in right away, * so nobody is waiting on this link and it has to survive a mail read the next * morning. The other two answer something the user just asked for. */ emailVerification?: string; /** Password-reset link from `createForgotPasswordHandler`. @default '1h' */ passwordReset?: string; /** * Confirmation link sent to the new address by `createChangeEmailHandler`. * @default '1h' */ emailChange?: string; } export interface RateLimitConfig { windowMs: number; max: number; /** * Optional persistent store (Redis, Prisma, Upstash, etc.) implementing * `RateLimitStore`. When omitted, defaults to an in-memory Map — suitable * only for single-process deployments. */ store?: import('./server/rate-limit.js').RateLimitStore; } /** * Optional Double-Submit-Cookie configuration on top of the always-on * Origin-header CSRF check. When `doubleSubmit` is true, mutating requests * must echo the value of the CSRF cookie in the configured header. Enable it * only when every cookie-authenticated mutation sends that header (package * stores/components or `csrfFetch`) — SvelteKit remote-function and native * no-JS form posts cannot, and would 403 (AUTH.md → production checklist). */ export interface CsrfConfig { doubleSubmit?: boolean; cookieName?: string; headerName?: string; /** * Secure attribute for the CSRF cookie. Default `true`; set `false` for * non-HTTPS dev. **Package-wide** — see `JwtConfig.cookieSecure` for the five * decisions an explicit `false` on any of the three cookie configs drives. */ cookieSecure?: boolean; /** Override the SameSite attribute. Default `'lax'`. */ cookieSameSite?: 'lax' | 'strict' | 'none'; /** * Prefix the CSRF cookie name with `__Host-`, which hardens against * subdomain cookie injection (the browser only accepts such a cookie over * HTTPS with `Path=/` and no `Domain`). Opt-in and HTTPS-only — incompatible * with a `cookieSecure: false` on ANY of the three cookie configs, since that * declares the whole deployment non-HTTPS (warned about in * `createAuthHandle`). The * client must be told too: pass `useHostPrefix: true` in the `csrf` config of * the bundled stores/components (same place as `cookieName`), or to * `csrfFetch`/`readCsrfToken` in custom client code. */ useHostPrefix?: boolean; } /** * Opt-in refresh-token rotation. When enabled (and `repos.refreshToken` is * provided), login/register/passkey-auth issue both a short-lived access * token (via the JWT cookie) and a longer-lived refresh token stored as a * SHA-256 hash. The handle hook transparently rotates both cookies once * the access token expires, so client code needs no changes. Reuse of a * *revoked* refresh token invalidates the entire token family. * * Non-breaking: without this config the existing 7-day JWT-only behaviour * stays in place. */ export interface RefreshTokenConfig { /** TTL for the short-lived access token. Accepts `60s` / `15m` / `2h` / `7d`. Default `15m`. */ accessTokenTtl?: string; /** TTL for the long-lived refresh token. Default `30d`. */ refreshTokenTtl?: string; /** Cookie name for the refresh token. Default `'refresh'`. */ cookieName?: string; /** * Cookie path scope. Default `'/'` so the handle hook can rotate * transparently on any request (the cookie is `httpOnly`+`secure`+ * `sameSite=lax`, so scope is the right trade-off). Narrow to * `'/api/auth'` for defense-in-depth — transparent rotation in the hook * is then disabled and consumers must call `createRefreshHandler` * explicitly from the client. */ cookiePath?: string; /** * Secure attribute for the refresh cookie. Default `true`; set `false` for * non-HTTPS dev. **Package-wide** — see `JwtConfig.cookieSecure` for the five * decisions an explicit `false` on any of the three cookie configs drives. */ cookieSecure?: boolean; /** Override the SameSite attribute for the refresh cookie. Default `'lax'`. */ cookieSameSite?: 'lax' | 'strict' | 'none'; } /** * Opt-in TOTP two-factor authentication (RFC 6238). When set (and the * `backupCode` repo is provided), a user can enable an authenticator-app second * factor on top of their password; the login then runs a two-step flow * (password → code). Passkey logins are NOT gated — which holds because * `webauthn.requireUserVerification` defaults to `true`, so a passkey assertion * proves possession *and* a PIN/biometric; opting out of UV alongside this * config makes a passkey login single-factor for a TOTP user, and * `createPasskeyHandlers` warns about that pairing. Wiring 2FA requires * `encryptionKey`; everything else has secure defaults. */ export interface TwoFactorConfig { /** * Key material used to encrypt the TOTP secret at rest (AES-256-GCM). MUST be * **high-entropy** (e.g. 32 random bytes, base64) and stable across restarts — * it is hashed to a 32-byte AES key, not stretched like a password, so a weak * value weakens the at-rest protection. Required whenever 2FA is wired. * * **Rotating it invalidates every stored secret, and re-enrolment does not * route around that**: `setup` refuses while `totpEnabled` is set. An * affected user gets back in with a backup code (the verify handler keeps * that path open when the secret is unreadable, and logs the failure) or, if * they enrolled one, with a passkey — passkey logins are not TOTP-gated, so * this key never touches them. Treat a rotation as an incident with a * runbook — see the AUTH.md key-rotation runbook for * `twoFactor.encryptionKey`. */ encryptionKey: string; /** * Issuer label shown in the authenticator app (the `issuer` of the otpauth * URI). Defaults to the host of `appUrl`. Keep it stable — changing it shows * up as a renamed account in existing authenticators. */ issuer?: string; /** * TOTP HMAC algorithm. `'SHA-1'` (default) is the RFC-6238 baseline and the * only one Google/Microsoft/etc. authenticators reliably support — not a * weakness given the high-entropy secret. Opt into `'SHA-256'`/`'SHA-512'` * only if your users' apps handle them. */ algorithm?: import('./server/totp.js').TotpAlgorithm; /** Number of digits in the code. @default 6 */ digits?: number; /** TOTP time-step in seconds. @default 30 */ period?: number; /** * Verification drift tolerance, in ± periods. @default 1 (accepts the * previous/next 30s window for clock skew). Raising it widens the brute-force * surface — keep it small and rely on `rateLimit.twoFactor`. */ window?: number; /** * TTL of the short-lived pending-2FA token issued between password and code. * Accepts `30s` / `5m` / `1h`. @default '5m'. */ pendingTokenTtl?: string; /** How many single-use backup codes to issue at enable. @default 10 */ backupCodeCount?: number; } /** * Outbound-email settings shared by every transactional mail the package sends * (verification, password-reset, email-change, invitation). Currently just the * default sender; grouped as an object so future mail-wide options (reply-to, * a sender display-name override) land here without widening `AuthConfig`. */ export interface EmailConfig { /** * Default `From` for all auth emails — a bare address (`auth@example.com`) or * an RFC-5322 display-name form (`"Acme "`). Threaded into * every `EmailTransport.send({ from })` call a handler makes; a per-mail builder hook * (e.g. `inviteEmail`) may still override it. When omitted, the transport's * own default applies (e.g. a Lettermint verified sender), so this is optional * but recommended in production for a consistent, deliverable sender. */ from?: string; /** * Sender display name. When set, it is combined with a bare `from` address * into the RFC-5322 `"Name "` form for the default mail builders. If * `from` already carries a display name, this is ignored. Purely cosmetic — * affects how the sender shows in the recipient's client. */ fromName?: string; /** * Application name shown in the default transactional mail copy (subjects + * bodies, the `{appName}` placeholder). Defaults to the host of `appUrl`. * Ignored when a per-mail builder hook supplies its own copy. */ appName?: string; /** * Locale for the **default** transactional mails (verification, password-reset, * email-change, invitation). The handlers resolve the matching `AuthLocale` * bundle server-side (SSR-safe) and pass it to the default builders, so the * mails localize out of the box. Unknown/omitted → English. A per-mail builder * hook overrides this entirely. Bundles ship for `en`/`de`. */ locale?: import('@urbicon-ui/i18n').Locale; } /** * Sink for the package's operational log output — construction-time * misconfiguration warnings and runtime failures that deliberately do not * fail the request (a failed refresh-token revoke on logout, a backup-code * cleanup error, an invitation-email failure). Console-compatible, so the * default is simply `console`; pass your structured logger to route these * into an error tracker instead of stdout; previously these paths were * console-only and invisible to consumer logging. * * Exceptions thrown by the sink are swallowed (`createAuthDeps` shields every * call): several sites log inside detached fire-and-forget work or after a * security-relevant write has already committed, and a broken logging * transport must never break the auth flow it observes. */ export interface AuthLogger { warn(message: string, ...context: unknown[]): void; error(message: string, ...context: unknown[]): void; } export interface AuthConfig { jwt: JwtConfig; /** * Where operational warnings/errors go. Default: `console`. See * {@link AuthLogger} for what is routed here. */ logger?: AuthLogger; /** * Trusted base URL of the deployment, used to build links in outbound * emails (verify-email, password-reset). Required: do NOT derive these * URLs from `request.url` because the Host header is attacker-controlled * and a spoofed value would point reset/verify links at an attacker's * domain, leaking the raw token. Format: scheme + host (+ optional path), * e.g. `'https://app.example.com'`. No trailing slash required. */ appUrl: string; /** * Outbound-email settings (currently the default sender `from`). Optional; see * {@link EmailConfig}. The resolved `from` is threaded into every handler's * email send so all transactional mail shares a consistent sender. */ email?: EmailConfig; password?: PasswordConfig; /** * Account-lockout policy after repeated failed logins. Leave unset to get a * safe default (5 attempts / 15 min) — applied at read time, so a hand-built * `AuthDeps` that never went through `createAuthDeps` gets it too. Defaulted * only when you configured neither `rateLimit` nor `lockout`: configuring * rate-limiting is engagement with the defense, and the lockout carries its * own DoS trade-off (AUTH.md → Known Limitations). Pass `null` to opt out * explicitly — `createAuthDeps` then warns in a production config. */ lockout?: LockoutConfig | null; /** * Lifetimes of the three mailed single-use tokens (email verification, * password reset, email change). Every key is optional and defaults to the * window the shipped handlers use; see {@link TokenTtlConfig}. */ tokenTtl?: TokenTtlConfig; /** * Per-handler rate limits keyed by client IP. **Every key carries a secure * default** — the default table is derived from this interface, so a key * cannot ship without one — and configuring some keys is a *merge*, never a * replacement (setting `register` never leaves `login` unprotected). * * Two opt-outs, at two scopes: `rateLimit: null` disables limiting for every * handler, and a single key set to `null` disables just that one * (`rateLimit: { register: null }` = "deliberately unlimited registration", * without giving up the login brake). An *omitted* key is not an opt-out — * it gets the default. Both are warned about in a production config when they * leave login unprotected. * * The per-key numbers and the reasoning behind each are in docs/AUTH.md. */ rateLimit?: { login?: RateLimitConfig | null; register?: RateLimitConfig | null; /** * Limit for the password-reset *request* handler (`forgot-password`). * Named after its endpoint so it cannot be confused with `resetPassword`, * the consume half of the same flow. */ forgotPassword?: RateLimitConfig | null; /** Limit for the password-reset *consume* handler (`reset-password`). */ resetPassword?: RateLimitConfig | null; /** Limit for the email-verification handler (`verify-email`). */ verifyEmail?: RateLimitConfig | null; /** Limit for the explicit refresh endpoint (`refresh`). */ refresh?: RateLimitConfig | null; /** Limit for passkey authentication (options + verify). */ passkeyAuth?: RateLimitConfig | null; /** * Limit for the authenticated change-password handler. It is re-auth gated, * but still credential-accepting, so limiting it stops a hijacked session * from brute-forcing the current password through this endpoint. */ changePassword?: RateLimitConfig | null; /** Limit for the authenticated change-email request handler. */ changeEmail?: RateLimitConfig | null; /** Limit for the authenticated delete-account handler. */ deleteAccount?: RateLimitConfig | null; /** * Limit for the authenticated 2FA-disable handler. Re-auth gated but * credential-accepting — and the most valuable brute-force target of the * re-auth family, since success removes the second factor. Defaulted * unconditionally, like every other key: the handler that reads it only * exists when 2FA is wired, and a condition here would be a second * hand-maintained list of the kind the derived table removes. */ twoFactorDisable?: RateLimitConfig | null; /** * Limit for the 2FA verify handler (the second login step). Brute-force * critical — a 6-digit code has only 10^6 combinations — so it carries a * strict default, injected whether or not `config.twoFactor` is set (see * `twoFactorDisable`). Configure it explicitly to tune the limit. */ twoFactor?: RateLimitConfig | null; } | null; csrf?: CsrfConfig; /** * Optional overrides for the response security headers `createAuthHandle` * applies. The always-on headers (nosniff, X-Frame-Options, Referrer-Policy, * Permissions-Policy) need no config; HSTS (emitted only on an HTTPS * deployment — no `cookieSecure: false` on the session, CSRF or refresh * cookie) and CSP carry secure defaults you can tune or disable here. */ securityHeaders?: import('./server/security-headers.js').SecurityHeadersConfig; refreshToken?: RefreshTokenConfig; /** * Active-session listing (the `SessionManager` feature). A "session" is a * refresh-token family, so this **requires `refreshToken` rotation** — without * it there is nothing server-side to list and the endpoints report * unavailable. `storeIp` opts into persisting the client IP alongside the * user-agent on each session row; it is personal data (GDPR), so it defaults * to `false` — the user-agent alone drives device recognition in the UI. */ sessions?: { storeIp?: boolean; }; /** * TOTP two-factor authentication (the `TwoFactorManager` feature + login * two-step). Opt-in: when set, also provide `repos.backupCode`. Requires * `twoFactor.encryptionKey`. See {@link TwoFactorConfig}. */ twoFactor?: TwoFactorConfig; routes?: { afterLogin?: string; afterLogout?: string; loginPage?: string; }; /** * Consumer callbacks fired at named points in the auth flows. Every one * states what a throw inside it does, and there are only two answers: * * - **Caught and logged** — the hook only reports, so a failure inside it * must not change the status the handler had earned. The error goes to * `config.logger.error`; it is never swallowed. This is every hook except * the two below. * - **Aborts the request** — `onBeforeAccountDelete` and `transformUser` * gate an outcome that has not happened yet, so the caller must see the * throw. * * The split follows from what a hook *is*, not from where it is called: an * observer never gets to fail a request, a gate always does. */ hooks?: { /** * Fires after `createRegisterHandler` has created the account, spent the * single-use invitation and sent the verification mail — and before the * auto-login session is established. Receives the sanitized user. * * A throw is caught and logged. The invitation is already consumed by this * point, so a failed response would invite a retry that answers * `invitation_used` 403. */ onUserCreated?: (user: AuthUser) => Promise; /** * Fires after a new password hash was written and every other session * invalidated — from `createChangePasswordHandler` (re-auth) and from * `createResetPasswordHandler` (reset token, already spent). * * A throw is caught and logged: the old password no longer works by the * time this runs, so the change cannot be reported as failed. */ onPasswordChanged?: (userId: string) => Promise; /** * Fires once a session is established, from all three login paths: the * password login (`createLoginHandler`), the TOTP second step * (`createTwoFactorHandlers` → `verify`) and a passkey assertion * (`createPasskeyHandlers`). Receives the sanitized user — the audit seam * for "who signed in". * * A throw is caught and logged; the session cookie is already set. */ onLoginSuccess?: (user: AuthUser) => Promise; /** * Fires on every rejected login. `reason` is `user_not_found` or * `invalid_password` on the password path, and one of `challenge_missing`, * `unknown_credential`, `user_handle_mismatch`, `credential_deleted`, * `counter_regression`, `user_not_found`, `invalid_assertion` on the * passkey path — where `email` is `''`, because those ceremonies resolve no * address (the reason disambiguates). * * A throw is caught and logged. On the `invalid_password` path the failed * attempt has already been counted towards the lockout, so a `500` would * spend that budget while hiding from the user that their password was * wrong; on the others it would replace a precise `401` with a status that * invites a retry. */ onLoginFailed?: (email: string, reason: string) => Promise; /** * Fires when issuing a password-reset email fails. The forgot-password * handler decouples the token write + email send from its response (so the * response time can't reveal whether the account exists), which means such * a failure can't surface as an HTTP error — the user was already told * "if the account exists, an email was sent". Wire this to your error * tracker so a broken mail transport doesn't silently lock users out of * recovery. A throw is caught and logged — it runs detached from the * response, where an unguarded one would surface as an unhandled rejection. * Note: on serverless/edge runtimes that freeze the worker after the * response, neither this hook nor the internal log may run — use a * queue-backed email transport there for guaranteed delivery. */ onPasswordResetFailed?: (email: string, err: unknown) => Promise; /** * Fires when an invitation row was created but its invite email failed to * send (`createInvitationHandlers`). The handler still returns 201 with * `emailSent: false`, so this failure never reaches the invitee — wire it * to your error tracker (or a resend queue) so a broken mail transport * doesn't silently leave invited users unable to register (registration is * invitation-gated). A throwing hook is caught and never breaks the * response. */ onInvitationEmailFailed?: (email: string, err: unknown) => Promise; /** * Fires when a user requests an email change (`createChangeEmailHandler`), * once the verification token has been persisted and the confirmation mail * dispatched to the new address. Like forgot-password, this runs decoupled * from the response (so timing can't reveal whether the target address was * already taken), so a throw is caught and logged rather than surfaced. * `newEmail` is the *pending* address — the change is not yet effective. */ onEmailChangeRequested?: (userId: string, newEmail: string) => Promise; /** * Fires when issuing an email change fails (`createChangeEmailHandler`). * Like forgot-password, the token write + mails run decoupled from the * response (so timing can't reveal whether the target was taken), so such a * failure can't surface as an HTTP error — the user was already told to * check their new inbox. Wire this to your error tracker so a broken mail * transport doesn't silently leave a user waiting for a mail that never * arrives. A throw is caught and logged, like `onPasswordResetFailed`, and * carries the same serverless/edge caveat. */ onEmailChangeFailed?: (userId: string, newEmail: string, err: unknown) => Promise; /** * Fires when an email change is confirmed via the verification link * (`createVerifyEmailChangeHandler`) and the address has actually been * swapped on the row. `newEmail` is now the user's current, verified email. * A throw is caught and logged so it can't roll back a committed change. */ onEmailChanged?: (userId: string, newEmail: string) => Promise; /** * Fires immediately **before** a self-service account deletion * (`createDeleteAccountHandler`) removes the row — the name says so, unlike * the perfect-tense `onAccountDeleted` it replaces. Receives * the sanitized user so the consumer can archive or anonymise app-owned * data while it still exists. It runs after re-auth and inside the request, * so a throw **aborts** the deletion (fail-closed: don't erase if archiving * failed). Make your handler resilient/idempotent if you don't want a * transient failure to block erasure. */ onBeforeAccountDelete?: (user: AuthUser) => Promise; /** * Shape the object placed on `event.locals.user` for every authenticated * request. Runs in the handle hook right after the session is resolved and * the user loaded, receiving the **sanitized** user (the five public * `AuthUser` fields — never the password hash) and the request event. The * return value becomes `event.locals.user`, so type it through your * `App.Locals` declaration. May be async — the result is awaited. * * This is the seam for attaching app-specific data (tenant/household id, * plan, entitlements, locale). `AuthUser` is intentionally fixed and the * loaded row carries no custom columns, so load any extra data here keyed * by `user.id`: doing it in this one hook avoids a second `handle` that * re-resolves the session and keeps `locals.user` consistently typed. * * A throw fails the request (wrap your own logic in try/catch for * resilience) — with one exception it cannot be allowed to break: when the * request arrived on an expired access cookie and the handle hook rotated * the refresh token to keep it going, the rotation is already committed and * its replacement cookies are staged on the response. There a throw is * caught and logged and the request continues unauthenticated, because * losing those cookies would strand the browser on a spent token and get * the whole refresh family revoked as a suspected theft. * * Whatever you return lands on `locals.user`, so don't add secrets if * `locals.user` is serialized to the client. * * **Shape contract:** the notification handlers (stream, preferences, * push-subscription, CRUD) identify the caller via `locals.user.id`, so * the object you return MUST keep a string `id` at the top level (e.g. * spread the user: `{ ...user, tenant }` — not `{ auth: user, tenant }`). * A reshaped object without one gets a 401 from those handlers. The * passkey and account handlers are unaffected — they re-resolve the * session cookie and never read `locals.user`. */ transformUser?: (user: AuthUser, event: import('@sveltejs/kit').RequestEvent) => unknown; }; }