@fonderie/auth 7.19.1 → 7.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +3 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/services/jwt.ts","../../src/config.ts","../../src/models/user.model.ts","../../src/models/session.model.ts","../../src/services/session-policy.ts","../../src/services/request-location.ts","../../src/middlewares/session.ts","../../src/middlewares/require-auth.ts"],"sourcesContent":["import { withSession } from './session';\nimport { requireAuth } from './require-auth';\n\nconst middlewares = {\n\trequireAuth,\n\twithSession,\n};\n\nexport default middlewares;\n","import { createHash, randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport { DEFAULT_ACCESS_TOKEN_DURATION, DEFAULT_SESSION_DURATION, type IAuthConfig } from '../config';\n\nexport interface TokenPair {\n\taccessToken: string;\n\trefreshToken: string;\n\t// Server-side session id both tokens are bound to. Store it on the\n\t// fonderie_sessions row — deleting that row revokes the access token\n\t// immediately, not just the refresh token.\n\tsid: string;\n}\n\nexport interface IAccessPayload {\n\tsub: string; // userId\n\ttype: 'access';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tmfaPending?: boolean;\n\t// Absent only on legacy tokens issued before session binding and on\n\t// short-lived mfaPending tokens (no session exists yet at that point).\n\tsid?: string;\n\t// When the user last actually signed in (seconds). Absent on tokens issued\n\t// before Phase 3.\n\tauth_time?: number;\n}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n\tauth_time?: number;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\n\t// Keep an existing session id (a refresh rotates the same device session);\n\t// absent ⇒ a new session.\n\tsid?: string;\n\t// The original sign-in time (seconds since epoch) to carry through a\n\t// refresh; absent ⇒ now (this IS a sign-in).\n\tauthTime?: number;\n}\n\n// ── Key ring ──────────────────────────────────────────────────────────────\n// Tokens are signed with the CURRENT secret and carry its key id (`kid`).\n// Verification picks the secret by kid among [jwtSecret, ...jwtPreviousSecrets],\n// so rotating the secret signs nobody out: move the old one to\n// jwtPreviousSecrets, deploy, and drop it once the longest-lived token signed\n// with it has expired. The kid is derived from the secret — stable, no extra\n// config, and a truncated hash reveals nothing usable.\nexport function keyIdOf(secret: string): string {\n\treturn createHash('sha256').update(secret).digest('hex').slice(0, 16);\n}\n\nfunction keyRing(config: IAuthConfig): string[] {\n\treturn [config.jwtSecret, ...(config.jwtPreviousSecrets ?? [])].filter((s): s is string => typeof s === 'string' && s.length > 0);\n}\n\nexport function issueMfaPendingToken(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\tloginMethod: 'email' | 'phone',\n): string {\n\treturn jwt.sign(\n\t\t{\n\t\t\tsub: userId,\n\t\t\ttype: 'access',\n\t\t\tloginMethod,\n\t\t\tphoneVerified: false,\n\t\t\tmfaPending: true,\n\t\t} satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: '5m', keyid: keyIdOf(config.jwtSecret) },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\t// Lifetimes may come from an app's runtime resolver (console values). An\n\t// unreadable one — e.g. the text \"undefined\" from String(undefined) — must\n\t// fall back, not make jsonwebtoken throw on every sign-in and refresh.\n\tconst duration = validDuration(config.sessionDuration, DEFAULT_SESSION_DURATION, 'sessionDuration');\n\tconst accessDuration = validDuration(config.accessTokenDuration, DEFAULT_ACCESS_TOKEN_DURATION, 'accessTokenDuration');\n\t// When the user last actually signed in (seconds): a refresh carries it, a\n\t// sign-in sets it. Step-up (\"re-authenticate for sensitive actions\") reads it.\n\tconst authTime = options.authTime ?? Math.floor(Date.now() / 1000);\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = options.sid ?? randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid, auth_time: authTime } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t// jwtid: every token is unique. With the session id kept across rotations,\n\t\t// two tokens issued in the same second would otherwise be byte-identical —\n\t\t// and a rotated-away token indistinguishable from the live one.\n\t\t{ expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret), jwtid: randomUUID() } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid, auth_time: authTime } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration, keyid: keyIdOf(config.jwtSecret), jwtid: randomUUID() } as SignOptions,\n\t);\n\n\treturn { accessToken, refreshToken, sid };\n}\n\nexport function refreshTokenExpiry(token: string): Date {\n\tconst decoded = jwt.decode(token) as { exp?: number } | null;\n\treturn decoded?.exp\n\t\t? new Date(decoded.exp * 1000)\n\t\t: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);\n}\n\nexport function verifyToken(\n\ttoken: string,\n\tconfig: IAuthConfig,\n): IAccessPayload | IRefreshPayload | null {\n\tconst ring = keyRing(config);\n\tconst kid = (jwt.decode(token, { complete: true }) as { header?: { kid?: unknown } } | null)?.header?.kid;\n\t// A kid names exactly one secret; a token without one (signed before key\n\t// ids) is tried against each. An unknown kid verifies against nothing.\n\tconst candidates = typeof kid === 'string' ? ring.filter((s) => keyIdOf(s) === kid) : ring;\n\tfor (const secret of candidates) {\n\t\ttry {\n\t\t\treturn jwt.verify(token, secret) as IAccessPayload | IRefreshPayload;\n\t\t} catch {\n\t\t\t// wrong key, bad signature or expired — try the next candidate\n\t\t}\n\t}\n\treturn null;\n}\n\n/** A jsonwebtoken-style duration ('90d', '12h', '30m', '45s', '2w', '1y') in ms; null if unreadable. */\nexport function durationMs(value: string | undefined): number | null {\n\tconst m = /^\\s*(\\d+)\\s*(ms|s|m|h|d|w|y)?\\s*$/i.exec(value ?? '');\n\tif (!m) return null;\n\tconst unit = (m[2] ?? 's').toLowerCase();\n\tconst scale: Record<string, number> = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5, y: 31_557_6e5 };\n\treturn Number(m[1]) * (scale[unit] ?? 1e3);\n}\n\nconst warnedDurations = new Set<string>();\nfunction validDuration(value: string | undefined, fallback: string, name: string): string {\n\tif (value === undefined || value === null || value === '') return fallback;\n\tif (durationMs(String(value)) !== null) return String(value);\n\tconst key = `${name}=${String(value)}`;\n\tif (!warnedDurations.has(key)) {\n\t\twarnedDurations.add(key);\n\t\tconsole.warn(`[auth] ${name} ${JSON.stringify(value)} is not a duration (e.g. '90d', '1h') — using ${fallback}`);\n\t}\n\treturn fallback;\n}\n","import type { LocationResolver } from './services/request-location';\nimport type { IAuthRateLimitConfig } from './services/rate-limit';\nimport type { ClientKind, ISessionPolicy } from './services/session-policy';\nexport const DEFAULT_VERIFICATION_COOLDOWN = 5 * 60 * 1000; // 5 minutes\n// How long a device stays signed in WITHOUT being used (sliding: every refresh\n// extends it). Messaging apps effectively never sign an idle device out;\n// security comes from revocation (logged-in devices, reuse detection), not\n// from expiry. Was 7d. docs/SESSION-DESIGN.md, Phase 3.\nexport const DEFAULT_SESSION_DURATION = '90d';\n// Access tokens are refreshed silently, so they can be short: a stolen one is\n// useful for at most this long even while its session lives. Was 24h.\nexport const DEFAULT_ACCESS_TOKEN_DURATION = '1h';\n\n// Boot-time only — never resolvable at runtime\nexport interface IAuthSecrets {\n\tjwtSecret: string;\n\t// Previous signing secrets, still accepted for VERIFYING tokens while they\n\t// age out — so rotating jwtSecret signs nobody out. Rotation: move the old\n\t// secret here, set the new jwtSecret, deploy; remove it after the longest\n\t// session lifetime. Tokens carry a key id, so each is checked against one key.\n\tjwtPreviousSecrets?: string[];\n\t// Optional 32-byte key (64 hex chars, e.g. `openssl rand -hex 32`) that\n\t// encrypts TOTP secrets at rest (AES-256-GCM). Unset → secrets are stored\n\t// plaintext (backward-compatible). Set it in production; losing it makes\n\t// existing MFA secrets unrecoverable (users must re-enroll).\n\tmfaSecretKey?: string;\n\tgoogle?: {\n\t\tclientId: string;\n\t\tclientSecret: string;\n\t\tredirectUri: string;\n\t};\n\t// Sign in with Apple. Unlike Google, Apple's client secret is not a static\n\t// string — it's a short-lived ES256 JWT minted from a .p8 key at token\n\t// exchange, so the config carries the signing material, not a secret.\n\tapple?: {\n\t\t// Services ID (Sign in with Apple identifier) — the `aud` for the WEB\n\t\t// redirect flow and the `sub` of the client-secret JWT.\n\t\tclientId: string;\n\t\t// Apple Developer Team ID — the `iss` of the client-secret JWT.\n\t\tteamId: string;\n\t\t// Key ID of the .p8 signing key — the `kid` header of the client secret.\n\t\tkeyId: string;\n\t\t// Contents of the .p8 private key (PEM, `-----BEGIN PRIVATE KEY----- …`).\n\t\tprivateKey: string;\n\t\t// Web form_post callback URL registered with Apple.\n\t\tredirectUri: string;\n\t\t// iOS bundle identifiers whose NATIVE identityToken we accept. Native Sign\n\t\t// in with Apple mints id_tokens with `aud` = the app's bundle id (NOT the\n\t\t// Services ID), so each must be allow-listed for POST /auth/apple/native.\n\t\tnativeClientIds?: string[];\n\t};\n}\n\n// Behavioral — safe to expose to admin dashboard\nexport interface IAuthRuntimeConfig {\n\t// Idle timeout: a session unused this long ends (sliding). Default 90d.\n\tsessionDuration?: string;\n\t// Optional absolute cap: a session this old ends at its next refresh, however\n\t// active — the user signs in again (e.g. '365d'). Unset: no cap.\n\tsessionMaxAge?: string;\n\t// Lifetimes per platform (X-Client-Kind at sign-in: mobile | desktop | web),\n\t// over the shared values above. Presets apply when nothing is set — see\n\t// services/session-policy.ts for the full order.\n\tsessionPolicies?: Partial<Record<ClientKind, ISessionPolicy>>;\n\tverificationCooldown?: number;\n\tmfa?: boolean;\n\trequireVerification?: boolean;\n}\n\n// Type-checked: adding/renaming a field in IAuthRuntimeConfig breaks this at compile time\n// Per-platform keys: sessionPolicyConfigKeys(kind). readAuthRuntimeConfig reads them all.\nexport const AUTH_CONFIG_KEYS: Record<Exclude<keyof IAuthRuntimeConfig, 'sessionPolicies'>, string> = {\n\tsessionDuration: 'auth.session.duration',\n\tsessionMaxAge: 'auth.session.max_age',\n\tverificationCooldown: 'auth.verification.cooldown',\n\tmfa: 'auth.mfa.enabled',\n\trequireVerification: 'auth.verification.required',\n} as const;\n\nexport const MESSAGE_KEYS = {\n\temailRegistration: 'email-registration',\n\temailVerification: 'email-verification',\n\tpasswordReset: 'password-reset',\n\tphoneOtp: 'phone-otp',\n\tmfaEnabled: 'mfa-enabled',\n\tmfaDisabled: 'mfa-disabled',\n\tmfaBackupCodesRegenerated: 'mfa-backup-codes-regenerated',\n\temailChanged: 'email-changed',\n\tphoneChanged: 'phone-changed',\n\t// Changing HOW an account can be signed into is a security event for the\n\t// account's owner, who may not be the person doing it. Both directions are\n\t// notified for the same reason password changes are.\n\t// A password was REVOKED because the account it sat on had never been\n\t// verified and an OAuth identity proved ownership of the mailbox. The\n\t// recipient is now known to own that address, so this reaches the right\n\t// person — and it is the only way they learn a password stopped working.\n\tpasswordRevoked: 'password-revoked',\n\t// Welcome for an account CREATED by an OAuth sign-in. Distinct from\n\t// emailRegistration, which carries a verification PIN — an OAuth signup\n\t// arrives already verified by the provider and has nothing to confirm.\n\toauthRegistration: 'oauth-registration',\n\toauthLinked: 'oauth-linked',\n\toauthUnlinked: 'oauth-unlinked',\n} as const;\n\nexport type AuthMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];\n\nexport const EVENT_KEYS = {\n\tuserRegistered: 'fonderie.user.registered',\n\tuserDeleted: 'fonderie.user.deleted',\n\t// Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.\n\tuserPurged: 'fonderie.user.purged',\n\temailVerified: 'fonderie.user.email_verified',\n\tpasswordChanged: 'fonderie.user.password_changed',\n\t// Sessions the server revoked: { userId, sids (null = all), reason }. Reaches\n\t// the user's devices live (@fonderie/sse) so a revoked device signs out at\n\t// once. Reasons: 'terminated' (signed out from the devices list),\n\t// 'password-changed', 'admin', 'refresh-reuse' (theft signal).\n\tsessionRevoked: 'fonderie.session.revoked',\n} as const;\n\nexport type AuthEventKey = (typeof EVENT_KEYS)[keyof typeof EVENT_KEYS];\n\nexport interface IAuthConfig extends IAuthSecrets, IAuthRuntimeConfig {\n\t// Optional resolver: where is this request from? Auth calls it when it\n\t// records an auth event — every login attempt, every registration, and every\n\t// new session — and stores the result on that row (country / region / city\n\t// / time zone, plus network facts if the resolver knows them). Called at\n\t// most once per request. Absent ⇒ no location, exactly as before. The result\n\t// is sanitized and bounded; a resolver that throws or takes longer than\n\t// 500 ms leaves the row without a location and never fails the request.\n\t// In-process resolvers never approach that; the cap exists for a DB-backed\n\t// or hosted-API resolver (richer data: ISP, ASN, proxy/VPN).\n\t// On Vercel/Cloudflare: ({ headers }) => geoFromHeaders(headers, { trust })\n\t// from @fonderie/geo — zero infrastructure.\n\tlocation?: LocationResolver;\n\t// Adds the Secure attribute to auth cookies. Defaults to\n\t// NODE_ENV === 'production'; set explicitly when that heuristic is wrong.\n\tsecureCookies?: boolean;\n\t// Brute-force protection — ON by default, backed by the module's own\n\t// store adapter (distributed-correct across instances with zero config).\n\t// Inject a store (e.g. RedisStore) for high-throughput deployments,\n\t// override individual rules, or set false to disable entirely.\n\trateLimit?: IAuthRateLimitConfig | false;\n\t// Access-token lifetime (jsonwebtoken duration string). Default '1h'.\n\t// Access tokens are session-bound and die on logout / rotation /\n\t// password change regardless of this value; shorten it to bound the\n\t// window of a stolen token whose session is still alive.\n\taccessTokenDuration?: string;\n\tproviders: ('email' | 'phone' | 'google' | 'github' | 'apple')[];\n\tappName?: string;\n\t// Base URL for the password-reset LINK. When set, forgot-password emits a\n\t// ready-built `resetUrl` (base + `token=<high-entropy token>`) in the\n\t// notification payload, so a template can offer a click-to-reset link\n\t// instead of only the 6-digit code. Unset → resetUrl is '' and only the\n\t// pin flows (unchanged behaviour). The token backs POST /auth/email/reset\n\t// with `{ token, password }`.\n\tpasswordResetUrl?: string;\n\tresolve?: (ctx: { meta: Record<string, unknown> }) => Partial<IAuthRuntimeConfig>;\n\t// Override the HTTP path (and optionally method) of any auth route, keyed by a\n\t// stable id. Lets an app match an existing frontend's contract without a\n\t// gateway/shim, e.g. `{ forgotPassword: '/auth/forgot-password', updateProfile:\n\t// { method: 'PATCH', path: '/users/me' } }`. A bare string overrides the path;\n\t// an object can also change the method. Unset routes keep their defaults.\n\troutes?: Partial<Record<AuthRouteId, AuthRouteOverride>>;\n\t// Migrating an existing app onto Fonderie auth? Fonderie stores bcrypt\n\t// hashes, so a user imported with a foreign hash (argon2, scrypt, pbkdf2,\n\t// a framework's format, …) can't log in via the built-in bcrypt check.\n\t// Provide `legacyVerify` to validate that foreign hash on login; on the\n\t// first successful login Fonderie transparently re-stores the password as\n\t// bcrypt (rehash-on-login), so the legacy verifier is only ever hit once\n\t// per migrated user. Return true iff `plain` matches `hash`. If the imported\n\t// hashes are already bcrypt, you don't need this — the built-in check\n\t// accepts them and re-stores at the current cost factor automatically.\n\tlegacyVerify?: (plain: string, hash: string) => boolean | Promise<boolean>;\n\t// SAR export contributors from other modules (see IDataExportContributor).\n\tdataExportContributors?: IDataExportContributor[];\n}\n\n// A module's contribution to the per-user data export (SAR). The app wires\n// these (e.g. from @fonderie/workspaces) so auth can aggregate data owned by\n// other packages without importing them.\nexport interface IDataExportContributor {\n\tname: string;\n\tcollect: (userId: string) => Promise<unknown> | unknown;\n}\n\n// Stable ids for every auth route, for the `routes` path/method override map.\nexport type AuthRouteId =\n\t| 'providers' | 'unlinkOauth'\n\t| 'register' | 'login' | 'refresh'\n\t| 'forgotPassword' | 'resetPassword'\n\t| 'verifyEmail' | 'sendVerification'\n\t| 'logout'\n\t| 'me' | 'updateProfile' | 'updatePreferences' | 'updateEmail' | 'updatePhone' | 'changePassword' | 'deleteMe' | 'exportMe'\n\t| 'loginHistory' | 'listSessions' | 'terminateSession' | 'terminateOtherSessions'\n\t| 'mfaSetup' | 'mfaVerify' | 'mfaDisable' | 'mfaBackupCodes';\n\nexport type AuthRouteOverride = string | { method?: string; path?: string };\n\n/** Payload of fonderie.session.revoked. */\nexport interface ISessionRevokedEvent {\n\tuserId: string;\n\t/** The revoked sessions' sids; null = every session of the user. */\n\tsids: Array<string | null> | null;\n\treason: 'terminated' | 'password-changed' | 'admin' | 'refresh-reuse';\n}\n","import type { IStoreAdapter } from '@fonderie/store';\nimport type { IUser } from '../types';\n\nexport interface IUserUpdateFields {\n\tfirstName?: string;\n\tlastName?: string;\n\tphoneNumber?: string;\n\tavatarUrl?: string;\n\tlocale?: string;\n\ttimezone?: string;\n\tpreferences?: unknown;\n}\n\nconst USER_COLUMNS = `\n\tid,\n\temail,\n\tpassword_hash AS \"passwordHash\",\n\tfirst_name AS \"firstName\",\n\tlast_name AS \"lastName\",\n\tphone,\n\tprofile_image_url AS \"profileImageUrl\",\n\tlocale,\n\ttimezone,\n\tis_active AS \"isActive\",\n\tlast_login AS \"lastLogin\",\n\tpreferences,\n\tsuspended,\n\twhitelist,\n\tip_whitelist AS \"ipWhitelist\",\n\tmfa_enabled AS \"mfaEnabled\",\n\temail_verified_at AS \"emailVerifiedAt\",\n\tprovider,\n\tdeleted_at AS \"deletedAt\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nexport class UserModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\t// One page of users, newest first. Keyset-paginated on (created_at, id) —\n\t// the same cursor contract as login history and the audit log. The +1\n\t// over-fetch that detects a next page lives here so no outer clamp can\n\t// shave it off. Soft-deleted rows are excluded, as in every other finder.\n\tasync list(query: IUserListQuery = {}): Promise<IUserPage> {\n\t\tconst limit = Math.min(query.limit ?? 50, MAX_LIST_LIMIT);\n\t\tconst params: unknown[] = [];\n\t\tconst where: string[] = [query.deleted ? 'deleted_at IS NOT NULL' : 'deleted_at IS NULL'];\n\n\t\tif (query.cursor) {\n\t\t\tparams.push(query.cursor.createdAt, query.cursor.id);\n\t\t\twhere.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);\n\t\t}\n\n\t\tparams.push(limit + 1);\n\t\tconst rows = await this.store.query<IUser & { createdAtRaw?: string }>(\n\t\t\t`SELECT ${USER_COLUMNS}, created_at::text AS \"createdAtRaw\"\n\t\t\t FROM fonderie_users\n\t\t\t WHERE ${where.join(' AND ')}\n\t\t\t ORDER BY created_at DESC, id DESC\n\t\t\t LIMIT $${params.length}`,\n\t\t\tparams,\n\t\t);\n\t\treturn { users: rows.slice(0, limit), hasMore: rows.length > limit };\n\t}\n\n\t// For operator views only: a soft-deleted account still resolves, with\n\t// deletedAt set, so the console can say \"deleted on …\" instead of \"no such\n\t// user\". Nothing that authenticates or acts on an account may use this.\n\tasync findByIdIncludingDeleted(id: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findById(id: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NULL`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByEmail(email: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NULL`,\n\t\t\t[email],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByPhone(phone: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,\n\t\t\t[phone],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findOrCreateByPhone(\n\t\tphone: string,\n\t\tfirstName: string | null = null,\n\t\tlastName: string | null = null,\n\t\t// Only on insert: an existing account keeps the locale it chose.\n\t\tlocale: string | null = null,\n\t): Promise<{ id: string }> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (phone, first_name, last_name, locale)\n\t\t\tVALUES ($1, $2, $3, COALESCE($4, 'en-US'))\n\t\t\tON CONFLICT (phone) DO UPDATE\n\t\t\tSET first_name = COALESCE(EXCLUDED.first_name, fonderie_users.first_name),\n\t\t\t last_name = COALESCE(EXCLUDED.last_name, fonderie_users.last_name),\n\t\t\t updated_at = now()\n\t\t\tRETURNING id`,\n\t\t\t[phone, firstName, lastName, locale],\n\t\t);\n\t\treturn row!;\n\t}\n\n\tasync create(\n\t\temail: string,\n\t\tpasswordHash: string,\n\t\tfirstName: string | null,\n\t\tlastName: string | null,\n\t\t// The sign-up language, else the app's system locale; null keeps the\n\t\t// column default (en-US) for callers that predate locales.\n\t\tlocale: string | null = null,\n\t): Promise<{ id: string } | null> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (email, password_hash, first_name, last_name, locale)\n\t\t\tVALUES ($1, $2, $3, $4, COALESCE($5, 'en-US'))\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName, locale],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync update(id: string, fields: IUserUpdateFields): Promise<{ id: string } | null> {\n\t\tconst columnMap: Record<string, string> = {\n\t\t\tfirstName: 'first_name',\n\t\t\tlastName: 'last_name',\n\t\t\tphoneNumber: 'phone',\n\t\t\tavatarUrl: 'profile_image_url',\n\t\t\tlocale: 'locale',\n\t\t\ttimezone: 'timezone',\n\t\t\tpreferences: 'preferences',\n\t\t};\n\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tfor (const [key, col] of Object.entries(columnMap)) {\n\t\t\tif ((fields as Record<string, unknown>)[key] !== undefined) {\n\t\t\t\tvalues.push((fields as Record<string, unknown>)[key]);\n\t\t\t\tsets.push(`${col} = $${values.length}`);\n\t\t\t}\n\t\t}\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\t/**\n\t * Clear the OAuth provider, but ONLY when the account can still be signed\n\t * into afterwards.\n\t *\n\t * An account created through Google has no password. Unlinking it without\n\t * checking would not be \"removing a sign-in method\" — it would be locking\n\t * the owner out permanently, which is account deletion wearing a friendlier\n\t * label. The guard lives in the WHERE clause so the check and the write are\n\t * one atomic statement: a password cannot be removed between them.\n\t *\n\t * Returns false when the account has no password (nothing was changed) so\n\t * the caller can say why rather than reporting a silent success.\n\t */\n\tasync clearProvider(id: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET provider = NULL, provider_id = NULL\n\t\t\t WHERE id = $1\n\t\t\t AND password_hash IS NOT NULL\n\t\t\t AND provider IS NOT NULL\n\t\t\t RETURNING id`,\n\t\t\t[id],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\tasync updatePassword(id: string, passwordHash: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [\n\t\t\tpasswordHash,\n\t\t\tid,\n\t\t]);\n\t}\n\n\tasync markEmailVerified(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email_verified_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\t// The operator's lock: login, refresh and the session middleware all refuse a\n\t// suspended user. Returns false when there is no such user.\n\tasync setSuspended(id: string, suspended: boolean): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET suspended = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL RETURNING id`,\n\t\t\t[id, suspended],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\tasync softDelete(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET deleted_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync saveMfaSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_secret = $1 WHERE id = $2`, [secret, id]);\n\t}\n\n\tasync saveMfaPendingSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret_pending = $1, mfa_secret_pending_expires_at = now() + interval '15 minutes'\n\t\t\t WHERE id = $2`,\n\t\t\t[secret, id],\n\t\t);\n\t}\n\n\tasync getMfaPendingSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret_pending: string | null }>(\n\t\t\t`SELECT mfa_secret_pending FROM fonderie_users\n\t\t\t WHERE id = $1 AND mfa_secret_pending_expires_at > now()`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret_pending ?? null;\n\t}\n\n\tasync confirmMfaSecret(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret = mfa_secret_pending,\n\t\t\t mfa_secret_pending = NULL,\n\t\t\t mfa_secret_pending_expires_at = NULL,\n\t\t\t mfa_enabled = true,\n\t\t\t updated_at = now()\n\t\t\t WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync enableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_enabled = true WHERE id = $1`, [id]);\n\t}\n\n\tasync disableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET mfa_enabled = false, mfa_secret = NULL, updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync updateEmail(id: string, email: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email = $1, email_verified_at = NULL, updated_at = now() WHERE id = $2`,\n\t\t\t[email.toLowerCase().trim(), id],\n\t\t);\n\t}\n\n\tasync updatePhone(id: string, phone: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET phone = $1, updated_at = now() WHERE id = $2`,\n\t\t\t[phone, id],\n\t\t);\n\t}\n\n\tasync updatePreferences(\n\t\tid: string,\n\t\tfields: {\n\t\t\tlocale?: string;\n\t\t\ttimezone?: string;\n\t\t\tpatch?: Record<string, unknown>;\n\t\t},\n\t): Promise<{ id: string } | null> {\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tif (fields.locale !== undefined) {\n\t\t\tvalues.push(fields.locale);\n\t\t\tsets.push(`locale = $${values.length}`);\n\t\t}\n\t\tif (fields.timezone !== undefined) {\n\t\t\tvalues.push(fields.timezone);\n\t\t\tsets.push(`timezone = $${values.length}`);\n\t\t}\n\t\tif (fields.patch && Object.keys(fields.patch).length > 0) {\n\t\t\tvalues.push(JSON.stringify(fields.patch));\n\t\t\tsets.push(`preferences = preferences || $${values.length}::jsonb`);\n\t\t}\n\n\t\tif (sets.length === 0) return null;\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync getMfaSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret: string | null }>(\n\t\t\t`SELECT mfa_secret FROM fonderie_users WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret ?? null;\n\t}\n\n\t/**\n\t * Link an OAuth identity to the account owning `email`, creating it if there\n\t * is none.\n\t *\n\t * Returns what HAPPENED, not just who it happened to. Three outcomes hide\n\t * behind one upsert, and callers must tell them apart:\n\t *\n\t * • `inserted` — a brand-new account. Whoever provisions on signup (the\n\t * personal workspace, a wallet, a welcome email) must run, exactly as\n\t * it does for a password registration. This is the case that was\n\t * previously invisible: the upsert returned an id and looked identical\n\t * to a returning user signing in, so OAuth signups silently skipped\n\t * every signup side effect.\n\t * • `previousProvider` differs — an existing account gained, or switched,\n\t * a way to sign in. A security event for the owner.\n\t * • `previousProvider` is the same — an ordinary login. Nothing to do, and\n\t * notifying here would email the user on every single sign-in.\n\t *\n\t * The prior state is read in a CTE of the SAME statement, so it sees the\n\t * snapshot from before the insert. Reading it in a separate query would\n\t * race two concurrent logins into both seeing \"new\".\n\t *\n\t * SECURITY — linking is BY EMAIL, so this merges into whatever row already\n\t * holds the address. If that row was never verified, its password was never\n\t * proven to belong to the mailbox; anyone can register an address they do\n\t * not own. The provider HAS proven ownership. So an unverified password is\n\t * revoked at the moment of linking: without that, linking sets\n\t * email_verified_at and the earlier registrant is left holding a working\n\t * password on an account now treated as verified.\n\t *\n\t * The legitimate user loses nothing but a password reset — they own the\n\t * mailbox. Someone who does not own it cannot receive that mail.\n\t */\n\tasync upsertByProvider(\n\t\temail: string,\n\t\tprovider: string,\n\t\tproviderId: string,\n\t): Promise<{\n\t\tid: string;\n\t\tinserted: boolean;\n\t\tpreviousProvider: string | null;\n\t\tclearedUnverifiedPassword: boolean;\n\t} | null> {\n\t\tconst [row] = await this.store.query<{\n\t\t\tid: string;\n\t\t\tinserted: boolean;\n\t\t\tpreviousProvider: string | null;\n\t\t\tclearedUnverifiedPassword: boolean;\n\t\t}>(\n\t\t\t`WITH prior AS (\n\t\t\t\tSELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password\n\t\t\t\t FROM fonderie_users WHERE email = $1\n\t\t\t), upserted AS (\n\t\t\t\tINSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)\n\t\t\t\tVALUES ($1, now(), $2, $3)\n\t\t\t\tON CONFLICT (email) DO UPDATE\n\t\t\t\tSET provider = $2, provider_id = $3,\n\t\t\t\t email_verified_at = COALESCE(fonderie_users.email_verified_at, now()),\n\t\t\t\t -- A password on an UNVERIFIED account is a claim, not a\n\t\t\t\t -- credential: nobody ever proved that mailbox belongs to\n\t\t\t\t -- whoever set it. The provider has now proven it belongs to\n\t\t\t\t -- the person signing in. Drop the unproven claim rather than\n\t\t\t\t -- promote it — otherwise linking marks the account verified\n\t\t\t\t -- and silently hands the earlier registrant a working\n\t\t\t\t -- password on a now-trusted account.\n\t\t\t\t password_hash = CASE\n\t\t\t\t WHEN fonderie_users.email_verified_at IS NULL THEN NULL\n\t\t\t\t ELSE fonderie_users.password_hash\n\t\t\t\t END\n\t\t\t\tRETURNING id\n\t\t\t)\n\t\t\tSELECT upserted.id,\n\t\t\t NOT EXISTS (SELECT 1 FROM prior) AS inserted,\n\t\t\t (SELECT provider FROM prior) AS \"previousProvider\",\n\t\t\t -- True only when a password was actually revoked, so the\n\t\t\t -- caller can tell the owner their sign-in method changed.\n\t\t\t COALESCE((SELECT had_password AND email_verified_at IS NULL FROM prior), false)\n\t\t\t AS \"clearedUnverifiedPassword\"\n\t\t\tFROM upserted`,\n\t\t\t[email, provider, providerId],\n\t\t);\n\t\treturn row ?? null;\n\t}\n}\n\nconst MAX_LIST_LIMIT = 200;\n\nexport interface IUserListQuery {\n\tlimit?: number;\n\tcursor?: { createdAt: string; id: string };\n\t// true ⇒ ONLY soft-deleted accounts (an operator view; every other finder\n\t// excludes them). They stay until the retention purge hard-deletes them.\n\tdeleted?: boolean;\n}\n\nexport interface IUserPage {\n\t// Carries createdAtRaw (created_at::text) so the cursor keeps the\n\t// microsecond precision a Date round-trip would drop.\n\tusers: Array<IUser & { createdAtRaw?: string }>;\n\thasMore: boolean;\n}\n","import { createHash } from 'node:crypto';\n\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\nimport { CLIENT_KINDS, type ClientKind } from '../services/session-policy';\nimport { type IRequestLocation, type LocationResolver, resolveLocation } from '../services/request-location';\n\n// What `fonderie_sessions.token` holds: the SHA-256 of the refresh token, so a\n// database dump contains no working refresh token. Lookups also accept the raw\n// value, for rows written before migration 020 hashed them.\nexport function hashRefreshToken(token: string): string {\n\treturn createHash('sha256').update(token).digest('hex');\n}\n\n/** How long a just-rotated refresh token stays valid: a retry or a race. */\nexport const REFRESH_GRACE_MS = 30_000;\n\nexport interface ISessionRow {\n\tid: string;\n\tuserId: string;\n\tsid: string | null;\n\t/** What the stored `token` column holds now (a hash, or a legacy raw token). */\n\tstored: string;\n\t/** When the device signed in — the start of the absolute cap. */\n\tcreatedAt: Date;\n\t/** The platform declared at sign-in — its lifetimes apply at every refresh. */\n\tclientKind: ClientKind | null;\n}\n\n/** How a presented refresh token relates to the sessions table. */\nexport type RefreshMatch =\n\t| { kind: 'current'; row: ISessionRow }\n\t| { kind: 'grace'; row: ISessionRow }\n\t| { kind: 'reused'; row: ISessionRow }\n\t| { kind: 'unknown' };\n\nexport class SessionModel {\n\t// `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate locate?: LocationResolver,\n\t) {}\n\n\tasync create(\n\t\tuserId: string,\n\t\ttoken: string,\n\t\texpiresAt: Date,\n\t\tsid?: string,\n\t\tmeta?: IRequestMeta,\n\t): Promise<void> {\n\t\tconst location =\n\t\t\tthis.locate && meta?.headers\n\t\t\t\t? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers })\n\t\t\t\t: null;\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at, client_kind)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[\n\t\t\t\tuserId,\n\t\t\t\thashRefreshToken(token),\n\t\t\t\texpiresAt,\n\t\t\t\tsid ?? null,\n\t\t\t\tmeta?.userAgent ?? null,\n\t\t\t\tmeta?.ipAddress ?? null,\n\t\t\t\tlocation ? JSON.stringify(location) : null,\n\t\t\t\tmeta?.clientKind ?? null,\n\t\t\t],\n\t\t);\n\t}\n\n\tasync delete(token: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE token IN ($1, $2)`, [hashRefreshToken(token), token]);\n\t}\n\n\t/**\n\t * Where a presented refresh token stands: the session's current token, its\n\t * previous one within the grace (a retry or race), its previous one AFTER\n\t * the grace (reuse — a theft signal), or nothing known.\n\t */\n\tasync match(token: string): Promise<RefreshMatch> {\n\t\tconst hash = hashRefreshToken(token);\n\t\tconst [current] = await this.store.query<{ id: string; user_id: string; sid: string | null; token: string; created_at: Date; client_kind: string | null }>(\n\t\t\t`SELECT id, user_id, sid, token, created_at, client_kind FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,\n\t\t\t[hash, token],\n\t\t);\n\t\tif (current) return { kind: 'current', row: toRow(current) };\n\t\tconst [previous] = await this.store.query<{ id: string; user_id: string; sid: string | null; token: string; created_at: Date; client_kind: string | null; in_grace: boolean }>(\n\t\t\t`SELECT id, user_id, sid, token, created_at, client_kind, (previous_valid_until > now()) AS in_grace\n\t\t\t FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,\n\t\t\t[hash],\n\t\t);\n\t\tif (!previous) return { kind: 'unknown' };\n\t\treturn { kind: previous.in_grace ? 'grace' : 'reused', row: toRow(previous) };\n\t}\n\n\t/**\n\t * Rotate a session in place to a new refresh token: same row, same device.\n\t * From the CURRENT token it is optimistic — only if the stored token is\n\t * still the one matched; a concurrent rotation makes it return false and the\n\t * caller re-matches (and now finds the token as the previous one). The old\n\t * token becomes the previous, valid for the grace. From the PREVIOUS token\n\t * (within the grace) it is authorised by that hash and deadline alone, so\n\t * racing retries never starve; the previous and its deadline are kept.\n\t */\n\tasync rotate(\n\t\trow: ISessionRow,\n\t\tpresented: string,\n\t\tnext: { token: string; expiresAt: Date },\n\t\tfrom: 'current' | 'grace',\n\t): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_sessions\n\t\t\t SET previous_token_hash = CASE WHEN $5 THEN $6 ELSE previous_token_hash END,\n\t\t\t previous_valid_until = CASE WHEN $5 THEN now() + ($7 || ' milliseconds')::interval ELSE previous_valid_until END,\n\t\t\t token = $3,\n\t\t\t expires_at = $4,\n\t\t\t last_used_at = now()\n\t\t\t WHERE id = $1\n\t\t\t AND CASE WHEN $5 THEN token = $2\n\t\t\t ELSE previous_token_hash = $6 AND previous_valid_until > now() END\n\t\t\tRETURNING id`,\n\t\t\t[row.id, row.stored, hashRefreshToken(next.token), next.expiresAt, from === 'current', hashRefreshToken(presented), String(REFRESH_GRACE_MS)],\n\t\t);\n\t\treturn rows.length === 1;\n\t}\n\n\t/** Revoke one session by its row id (any user) — reuse detection, admin. */\n\tasync revokeById(id: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE id = $1`, [id]);\n\t}\n\n\t// Delete the session an access token is bound to (by its sid claim). Lets\n\t// logout revoke the current session without the client resending the refresh\n\t// token — the access token already identifies its session.\n\tasync deleteBySid(sid: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);\n\t}\n\n\t// Revoke every session for a user (e.g. on password change). Access tokens\n\t// bound to these sessions via the sid claim die on their next request.\n\tasync deleteByUser(userId: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);\n\t}\n\n\t// Session metadata for a user (no tokens) — for the data-export / SAR bundle.\n\tasync listByUser(userId: string): Promise<\n\t\tArray<{ id: string; userAgent: string | null; ipAddress: string | null; createdAt: Date; expiresAt: Date }>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Live sessions for the Active Sessions screen: unexpired only, carrying the\n\t// sid so the caller can flag the current one (row.sid === the JWT's sid).\n\t// No token — that never leaves the server.\n\tasync listLiveByUser(userId: string): Promise<\n\t\tArray<{\n\t\t\tid: string;\n\t\t\tsid: string | null;\n\t\t\tuserAgent: string | null;\n\t\t\tipAddress: string | null;\n\t\t\tlocation: IRequestLocation | null;\n\t\t\tclientKind: string | null;\n\t\t\tcreatedAt: Date;\n\t\t\texpiresAt: Date;\n\t\t}>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id, sid, client_kind AS \"clientKind\",\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t location,\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND expires_at > now()\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Terminate one session by row id, scoped to its owner (a user can never\n\t// delete another user's session). Returns whether a row was removed.\n\tasync terminateById(userId: string, id: string): Promise<boolean> {\n\t\treturn (await this.terminateByIdReturningSid(userId, id)) !== undefined;\n\t}\n\n\t/** Terminate one of the user's sessions; its sid (null if unbound), or undefined if none was removed. */\n\tasync terminateByIdReturningSid(userId: string, id: string): Promise<string | null | undefined> {\n\t\tconst rows = await this.store.query<{ sid: string | null }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING sid`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length ? (rows[0]?.sid ?? null) : undefined;\n\t}\n\n\t// Terminate all of a user's sessions except the current one (by sid).\n\t// Returns the number of sessions removed.\n\tasync terminateOthers(userId: string, keepSid: string): Promise<number> {\n\t\treturn (await this.terminateOthersReturningSids(userId, keepSid)).length;\n\t}\n\n\t/** Terminate all of the user's sessions but `keepSid`; the sids removed. */\n\tasync terminateOthersReturningSids(userId: string, keepSid: string): Promise<Array<string | null>> {\n\t\tconst rows = await this.store.query<{ sid: string | null }>(\n\t\t\t`DELETE FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)\n\t\t\t RETURNING sid`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.map((r) => r.sid);\n\t}\n\n\tasync exists(token: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now()`,\n\t\t\t[hashRefreshToken(token), token],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Liveness check for session-bound access tokens (by the sid claim).\n\tasync aliveBySid(sid: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE sid = $1 AND expires_at > now()`,\n\t\t\t[sid],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n}\n\nfunction toRow(r: { id: string; user_id: string; sid: string | null; token: string; created_at?: Date; client_kind?: string | null }): ISessionRow {\n\tconst kind = (CLIENT_KINDS as readonly string[]).includes(r.client_kind ?? '') ? (r.client_kind as ClientKind) : null;\n\treturn { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token, createdAt: r.created_at ? new Date(r.created_at) : new Date(), clientKind: kind };\n}\n","// Session lifetimes per platform (docs/SESSION-DESIGN.md, Phase 3c).\n//\n// A phone in someone's pocket and a browser on a shared computer should not\n// stay signed in equally long. The client declares its platform at SIGN-IN\n// (header X-Client-Kind: mobile | desktop | web); it is recorded on the\n// session and used at every refresh — a client cannot promote itself later.\n//\n// Each lifetime resolves, first match wins:\n// 1. the console, for this platform auth.session.duration.web\n// 2. the console, shared auth.session.duration\n// 3. the app's code, for this platform sessionPolicies.web\n// 4. the app's code, shared sessionDuration\n// 5. the preset for this platform SESSION_POLICY_PRESETS\n// 6. the library default 90 d idle / 1 h access\n// A client that declares nothing gets 2 → 4 → 6: today's behaviour.\n\nimport type { IAuthConfig, IAuthRuntimeConfig } from '../config';\n\nexport const CLIENT_KINDS = ['mobile', 'desktop', 'web'] as const;\nexport type ClientKind = (typeof CLIENT_KINDS)[number];\n\nexport interface ISessionPolicy {\n\t/** Idle timeout, sliding (e.g. '90d'). */\n\tsessionDuration?: string;\n\t/** Absolute cap from sign-in (e.g. '365d'). */\n\tsessionMaxAge?: string;\n\t/** Access-token lifetime (e.g. '1h'). */\n\taccessTokenDuration?: string;\n}\n\n/** Built-in defaults per platform; every value is overridable (see the order above). */\nexport const SESSION_POLICY_PRESETS: Readonly<Record<ClientKind, ISessionPolicy>> = {\n\t// Personal device; the token lives in the Keychain / Keystore.\n\tmobile: { sessionDuration: '90d', sessionMaxAge: '365d' },\n\t// Personal, but often a shared workstation.\n\tdesktop: { sessionDuration: '30d', sessionMaxAge: '180d' },\n\t// Shared and public computers; cookies.\n\tweb: { sessionDuration: '14d', sessionMaxAge: '90d' },\n};\n\n/** The platform a request declares, or null (undeclared or unknown values). */\nexport function clientKindOf(headers: Headers): ClientKind | null {\n\tconst v = headers.get('x-client-kind')?.trim().toLowerCase();\n\treturn (CLIENT_KINDS as readonly string[]).includes(v ?? '') ? (v as ClientKind) : null;\n}\n\nconst FIELDS = ['sessionDuration', 'sessionMaxAge', 'accessTokenDuration'] as const;\n\n/**\n * The config to issue tokens with for this platform: the app's config, its\n * runtime (console) values, and the lifetimes resolved in the order above.\n */\nexport function configForClient(\n\tconfig: IAuthConfig,\n\truntime: Partial<IAuthRuntimeConfig> | undefined,\n\tkind: ClientKind | null,\n): IAuthConfig {\n\tconst rt = (runtime ?? {}) as Partial<IAuthRuntimeConfig> & ISessionPolicy;\n\tconst code = config as IAuthConfig & ISessionPolicy;\n\tconst out: IAuthConfig & ISessionPolicy = { ...config, ...rt };\n\tfor (const field of FIELDS) {\n\t\tconst value =\n\t\t\t(kind ? rt.sessionPolicies?.[kind]?.[field] : undefined) ??\n\t\t\trt[field] ??\n\t\t\t(kind ? code.sessionPolicies?.[kind]?.[field] : undefined) ??\n\t\t\tcode[field] ??\n\t\t\t(kind ? SESSION_POLICY_PRESETS[kind][field] : undefined);\n\t\tif (value === undefined) delete out[field];\n\t\telse out[field] = value;\n\t}\n\treturn out;\n}\n\n/** Console keys for one platform's lifetimes. */\nexport function sessionPolicyConfigKeys(kind: ClientKind): Record<keyof ISessionPolicy, string> {\n\treturn {\n\t\tsessionDuration: `auth.session.duration.${kind}`,\n\t\tsessionMaxAge: `auth.session.max_age.${kind}`,\n\t\taccessTokenDuration: `auth.access.duration.${kind}`,\n\t};\n}\n\n/**\n * Read auth's runtime config from the app's config store, safely: a key that\n * is not set yields undefined — never the text \"undefined\" that\n * `String(getConfig(...)) || undefined` produces (which once made every refresh\n * fail). Use it as `resolve: (ctx) => readAuthRuntimeConfig((key) => getConfig(ctx, key, undefined))`.\n */\nexport function readAuthRuntimeConfig(read: (key: string) => unknown): Partial<IAuthRuntimeConfig> {\n\tconst text = (key: string) => {\n\t\tconst v = read(key);\n\t\treturn typeof v === 'string' && v.trim() ? v.trim() : undefined;\n\t};\n\tconst bool = (key: string) => {\n\t\tconst v = read(key);\n\t\tif (typeof v === 'boolean') return v;\n\t\tif (typeof v === 'string' && ['true', 'false'].includes(v.trim().toLowerCase())) return v.trim().toLowerCase() === 'true';\n\t\treturn undefined;\n\t};\n\tconst num = (key: string) => {\n\t\tconst v = read(key);\n\t\tconst n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() ? Number(v) : NaN;\n\t\treturn Number.isFinite(n) ? n : undefined;\n\t};\n\tconst out: Partial<IAuthRuntimeConfig> & ISessionPolicy = {};\n\tconst set = <K extends string>(k: K, v: unknown) => {\n\t\tif (v !== undefined) (out as Record<string, unknown>)[k] = v;\n\t};\n\tset('sessionDuration', text('auth.session.duration'));\n\tset('sessionMaxAge', text('auth.session.max_age'));\n\tset('accessTokenDuration', text('auth.access.duration'));\n\tset('verificationCooldown', num('auth.verification.cooldown'));\n\tset('mfa', bool('auth.mfa.enabled'));\n\tset('requireVerification', bool('auth.verification.required'));\n\tconst policies: Partial<Record<ClientKind, ISessionPolicy>> = {};\n\tfor (const kind of CLIENT_KINDS) {\n\t\tconst keys = sessionPolicyConfigKeys(kind);\n\t\tconst policy: ISessionPolicy = {};\n\t\tfor (const field of FIELDS) {\n\t\t\tconst v = text(keys[field]);\n\t\t\tif (v !== undefined) policy[field] = v;\n\t\t}\n\t\tif (Object.keys(policy).length) policies[kind] = policy;\n\t}\n\tif (Object.keys(policies).length) out.sessionPolicies = policies;\n\treturn out;\n}\n","// Optional location enrichment for auth events (login history, registrations,\n// sessions).\n//\n// Auth never resolves a location itself and imports no geo code: the app hands\n// in a resolver (IAuthConfig.location). Typical choices — the platform's\n// edge headers via @fonderie/geo's geoFromHeaders (zero infrastructure), a\n// self-hosted table, or a hosted IP API that also knows the network (ISP, ASN,\n// proxy/VPN, hosting). Field names match @fonderie/geo's GeoLocation, so its\n// result can be returned as-is.\n//\n// Whatever the resolver returns is treated as untrusted input: every field is\n// type-checked and bounded before it is stored, and a resolver that throws or\n// stalls costs the login nothing — the row is written without a location.\n\n/** Where a login attempt came from. Every field is optional; unknown is null. */\nexport interface IRequestLocation {\n\tcountry?: string | null; // ISO-3166-1 alpha-2\n\tcountryName?: string | null;\n\tsubdivision?: string | null; // ISO-3166-2 region part (QC, CA, ENG)\n\tsubdivisionName?: string | null;\n\tcity?: string | null;\n\t/** Postal / ZIP code as the provider reports it (94043, K1A 0B1, SW1A). Approximate. */\n\tpostalCode?: string | null;\n\tcontinent?: string | null; // two-letter continent code\n\ttimeZone?: string | null; // IANA name\n\tlatitude?: number | null;\n\tlongitude?: number | null;\n\t/** How far off the point may be, in kilometres (MaxMind's accuracy_radius).\n\t * Lets a UI say \"near Mountain View (±20 km)\" instead of implying precision. */\n\taccuracyRadius?: number | null;\n\t/** A stable, language-neutral place key (MaxMind/GeoNames id). Lets a reader\n\t * see the stored place in their own language later, and lets an app map it\n\t * to its own regions. Names above are a snapshot; this is the reference. */\n\tgeonameId?: number | null;\n\t/** Network facts — only resolvers backed by an IP-intelligence API know these. */\n\tisp?: string | null;\n\torg?: string | null;\n\tasn?: string | null; // e.g. \"AS15169\"\n\tmobile?: boolean | null;\n\tproxy?: boolean | null;\n\thosting?: boolean | null;\n}\n\nexport interface ILocationRequest {\n\t/** The resolved client IP (adapter-resolved, proxy-aware), or null. */\n\tip: string | null;\n\t/** The incoming request's headers — for platform geolocation headers. */\n\theaders: Headers;\n}\n\nexport type LocationResolver = (\n\treq: ILocationRequest,\n) => IRequestLocation | null | undefined | Promise<IRequestLocation | null | undefined>;\n\n// How long auth waits for the resolver before writing the row without a\n// location. Internal, deliberately not exported. It is zero-cost for in-process\n// resolvers (edge headers return in microseconds) and exists for the ones that\n// are not: @fonderie/geo's PostgresGeoProvider does a DB query that can hang\n// on an exhausted pool, and an app may choose a hosted IP-intelligence API for\n// richer data (ISP, ASN, proxy/VPN, accuracy radius). A legitimate lookup\n// answers in a few to ~150 ms; the session write that triggers resolution is\n// on the login path, so the cap bounds the worst-case delay a slow provider\n// can add to a sign-in.\nconst LOCATION_TIMEOUT_MS = 500;\n\nconst TEXT_MAX = 128;\n\nfunction str(v: unknown, re?: RegExp): string | null {\n\tif (typeof v !== 'string') return null;\n\tconst t = v.trim();\n\tif (t.length === 0 || t.length > TEXT_MAX) return null;\n\treturn re && !re.test(t) ? null : t;\n}\nfunction upperCode(v: unknown, re: RegExp): string | null {\n\tconst t = str(v, re);\n\treturn t ? t.toUpperCase() : null;\n}\nfunction bool(v: unknown): boolean | null {\n\treturn typeof v === 'boolean' ? v : null;\n}\n// Two decimals ≈ 1 km: plenty for \"which city\", and a login history should not\n// keep more precision than the city it names.\nfunction coord(v: unknown, limit: number): number | null {\n\tif (typeof v !== 'number' || !Number.isFinite(v) || Math.abs(v) > limit) return null;\n\treturn Math.round(v * 100) / 100;\n}\n\n// Kilometres, a positive whole number. MaxMind's values run 1–1000; anything\n// beyond the planet's half-circumference is not a radius.\nfunction radius(v: unknown): number | null {\n\tif (typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || v > 20_000) return null;\n\treturn Math.round(v);\n}\n\n// GeoNames ids are positive integers well inside 2^31.\nfunction placeId(v: unknown): number | null {\n\tconst n = typeof v === 'string' && /^\\d{1,10}$/.test(v) ? Number(v) : v;\n\treturn typeof n === 'number' && Number.isInteger(n) && n > 0 && n <= 2_147_483_647 ? n : null;\n}\n\n/** Keep only well-formed, bounded fields. Returns null when nothing survives. */\nexport function sanitizeLocation(input: unknown): IRequestLocation | null {\n\tif (!input || typeof input !== 'object') return null;\n\tconst i = input as Record<string, unknown>;\n\tconst out: IRequestLocation = {\n\t\tcountry: upperCode(i['country'], /^[A-Za-z]{2}$/),\n\t\tcountryName: str(i['countryName']),\n\t\tsubdivision: upperCode(i['subdivision'], /^[A-Za-z0-9]{1,3}$/),\n\t\tsubdivisionName: str(i['subdivisionName']),\n\t\tcity: str(i['city']),\n\t\tpostalCode: upperCode(i['postalCode'], /^[A-Za-z0-9][A-Za-z0-9 -]{0,11}$/),\n\t\tcontinent: upperCode(i['continent'], /^[A-Za-z]{2}$/),\n\t\ttimeZone: str(i['timeZone'], /^(UTC|[A-Za-z_]+(\\/[A-Za-z0-9_+-]+)+)$/),\n\t\tlatitude: coord(i['latitude'], 90),\n\t\tlongitude: coord(i['longitude'], 180),\n\t\taccuracyRadius: radius(i['accuracyRadius']),\n\t\tgeonameId: placeId(i['geonameId']),\n\t\tisp: str(i['isp']),\n\t\torg: str(i['org']),\n\t\tasn: upperCode(i['asn'], /^AS\\d{1,10}$/i),\n\t\tmobile: bool(i['mobile']),\n\t\tproxy: bool(i['proxy']),\n\t\thosting: bool(i['hosting']),\n\t};\n\t// Drop nulls so the stored JSON carries only what is known.\n\tconst known = Object.fromEntries(Object.entries(out).filter(([, v]) => v !== null)) as IRequestLocation;\n\treturn Object.keys(known).length > 0 ? known : null;\n}\n\n/** Run the resolver bounded by a timeout; never throws. */\n// One resolution per request: a sign-in writes a login-event row AND a session\n// row, and both ask. Keyed on the request's Headers object (unique per\n// request, collected with it), so the resolver — possibly a paid API — runs\n// once.\nconst perRequest = new WeakMap<Headers, { resolver: LocationResolver; result: Promise<IRequestLocation | null> }>();\n\nexport function resolveLocation(\n\tresolver: LocationResolver | undefined,\n\treq: ILocationRequest,\n\ttimeoutMs = LOCATION_TIMEOUT_MS,\n): Promise<IRequestLocation | null> {\n\tif (!resolver) return Promise.resolve(null);\n\tconst cached = perRequest.get(req.headers);\n\tif (cached && cached.resolver === resolver) return cached.result;\n\tconst result = resolveOnce(resolver, req, timeoutMs);\n\tperRequest.set(req.headers, { resolver, result });\n\treturn result;\n}\n\nasync function resolveOnce(\n\tresolver: LocationResolver,\n\treq: ILocationRequest,\n\ttimeoutMs: number,\n): Promise<IRequestLocation | null> {\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\ttry {\n\t\tconst result = await Promise.race([\n\t\t\tPromise.resolve().then(() => resolver(req)),\n\t\t\tnew Promise<null>((resolve) => {\n\t\t\t\ttimer = setTimeout(() => resolve(null), timeoutMs);\n\t\t\t}),\n\t\t]);\n\t\treturn sanitizeLocation(result);\n\t} catch (err) {\n\t\tconsole.warn('[auth] location resolver failed; recording without a location:', err);\n\t\treturn null;\n\t} finally {\n\t\tif (timer) clearTimeout(timer);\n\t}\n}\n","import type { Middleware } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IAuthConfig } from '../config';\nimport { verifyToken } from '../services/jwt';\nimport type { IAccessPayload } from '../services/jwt';\nimport { UserModel } from '../models/user.model';\nimport { SessionModel } from '../models/session.model';\n\n// Reads the Bearer token or session cookie, populates ctx.user\n// Does NOT reject — anonymous requests pass through\nexport function withSession(store: IStoreAdapter, config: IAuthConfig): Middleware {\n\tconst users = new UserModel(store);\n\tconst sessions = new SessionModel(store);\n\n\treturn async (ctx, next) => {\n\t\tconst token = extractToken(ctx.request);\n\t\tif (!token) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst payload = verifyToken(token, config);\n\t\tif (!payload || payload.type !== 'access') {\n\t\t\treturn next();\n\t\t}\n\n\t\t// Revocation: access tokens are bound to their refresh session via the\n\t\t// sid claim. Logout, rotation, and password change delete that row, so\n\t\t// the access token dies with it instead of living out its JWT expiry.\n\t\t// Tokens without a sid pass through: short-lived mfaPending tokens\n\t\t// (no session exists yet) and legacy tokens from before session\n\t\t// binding, which age out within one accessTokenDuration of deploy.\n\t\tif (payload.sid && !(await sessions.aliveBySid(payload.sid))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst user = await users.findById(payload.sub);\n\t\tif (!user || user.suspended || user.deletedAt) {\n\t\t\treturn next();\n\t\t}\n\n\t\tObject.assign(ctx, {\n\t\t\tuser: {\n\t\t\t\t...user,\n\t\t\t\tloginMethod: payload.loginMethod ?? 'email',\n\t\t\t\tphoneVerified: payload.phoneVerified ?? false,\n\t\t\t\tmfaPending: (payload as IAccessPayload).mfaPending ?? false,\n\t\t\t\t// The session this request is authenticated on, so the sessions\n\t\t\t\t// API can flag the current row and \"terminate others\" can spare it.\n\t\t\t\tsid: payload.sid ?? null,\n\t\t\t},\n\t\t});\n\n\t\treturn next();\n\t};\n}\n\nfunction extractToken(request: Request): string | null {\n\tconst auth = request.headers.get('authorization');\n\tif (auth?.startsWith('Bearer ')) {\n\t\treturn auth.slice(7);\n\t}\n\n\tconst cookie = request.headers.get('cookie') ?? '';\n\tconst match = cookie.match(/(?:^|;\\s*)access_token=([^;]+)/);\n\treturn match?.[1] ?? null;\n}\n","export { requireAuth, requireAnyAuth } from '@fonderie/core/middlewares';\n"],"mappings":"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","names":["jwt","import_node_crypto"]}
|
|
1
|
+
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/services/jwt.ts","../../src/config.ts","../../src/models/user.model.ts","../../src/models/session.model.ts","../../src/services/session-policy.ts","../../src/services/request-location.ts","../../src/middlewares/session.ts","../../src/middlewares/require-auth.ts"],"sourcesContent":["import { withSession } from './session';\nimport { requireAuth } from './require-auth';\n\nconst middlewares = {\n\trequireAuth,\n\twithSession,\n};\n\nexport default middlewares;\n","import { createHash, randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport { DEFAULT_ACCESS_TOKEN_DURATION, DEFAULT_SESSION_DURATION, type IAuthConfig } from '../config';\n\nexport interface TokenPair {\n\taccessToken: string;\n\trefreshToken: string;\n\t// Server-side session id both tokens are bound to. Store it on the\n\t// fonderie_sessions row — deleting that row revokes the access token\n\t// immediately, not just the refresh token.\n\tsid: string;\n}\n\nexport interface IAccessPayload {\n\tsub: string; // userId\n\ttype: 'access';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tmfaPending?: boolean;\n\t// Absent only on legacy tokens issued before session binding and on\n\t// short-lived mfaPending tokens (no session exists yet at that point).\n\tsid?: string;\n\t// When the user last actually signed in (seconds). Absent on tokens issued\n\t// before Phase 3.\n\tauth_time?: number;\n}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n\tauth_time?: number;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\n\t// Keep an existing session id (a refresh rotates the same device session);\n\t// absent ⇒ a new session.\n\tsid?: string;\n\t// The original sign-in time (seconds since epoch) to carry through a\n\t// refresh; absent ⇒ now (this IS a sign-in).\n\tauthTime?: number;\n}\n\n// ── Key ring ──────────────────────────────────────────────────────────────\n// Tokens are signed with the CURRENT secret and carry its key id (`kid`).\n// Verification picks the secret by kid among [jwtSecret, ...jwtPreviousSecrets],\n// so rotating the secret signs nobody out: move the old one to\n// jwtPreviousSecrets, deploy, and drop it once the longest-lived token signed\n// with it has expired. The kid is derived from the secret — stable, no extra\n// config, and a truncated hash reveals nothing usable.\nexport function keyIdOf(secret: string): string {\n\treturn createHash('sha256').update(secret).digest('hex').slice(0, 16);\n}\n\nfunction keyRing(config: IAuthConfig): string[] {\n\treturn [config.jwtSecret, ...(config.jwtPreviousSecrets ?? [])].filter((s): s is string => typeof s === 'string' && s.length > 0);\n}\n\nexport function issueMfaPendingToken(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\tloginMethod: 'email' | 'phone',\n): string {\n\treturn jwt.sign(\n\t\t{\n\t\t\tsub: userId,\n\t\t\ttype: 'access',\n\t\t\tloginMethod,\n\t\t\tphoneVerified: false,\n\t\t\tmfaPending: true,\n\t\t} satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: '5m', keyid: keyIdOf(config.jwtSecret) },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\t// Lifetimes may come from an app's runtime resolver (console values). An\n\t// unreadable one — e.g. the text \"undefined\" from String(undefined) — must\n\t// fall back, not make jsonwebtoken throw on every sign-in and refresh.\n\tconst duration = validDuration(config.sessionDuration, DEFAULT_SESSION_DURATION, 'sessionDuration');\n\tconst accessDuration = validDuration(config.accessTokenDuration, DEFAULT_ACCESS_TOKEN_DURATION, 'accessTokenDuration');\n\t// When the user last actually signed in (seconds): a refresh carries it, a\n\t// sign-in sets it. Step-up (\"re-authenticate for sensitive actions\") reads it.\n\tconst authTime = options.authTime ?? Math.floor(Date.now() / 1000);\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = options.sid ?? randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid, auth_time: authTime } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t// jwtid: every token is unique. With the session id kept across rotations,\n\t\t// two tokens issued in the same second would otherwise be byte-identical —\n\t\t// and a rotated-away token indistinguishable from the live one.\n\t\t{ expiresIn: accessDuration, keyid: keyIdOf(config.jwtSecret), jwtid: randomUUID() } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid, auth_time: authTime } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration, keyid: keyIdOf(config.jwtSecret), jwtid: randomUUID() } as SignOptions,\n\t);\n\n\treturn { accessToken, refreshToken, sid };\n}\n\nexport function refreshTokenExpiry(token: string): Date {\n\tconst decoded = jwt.decode(token) as { exp?: number } | null;\n\treturn decoded?.exp\n\t\t? new Date(decoded.exp * 1000)\n\t\t: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);\n}\n\nexport function verifyToken(\n\ttoken: string,\n\tconfig: IAuthConfig,\n): IAccessPayload | IRefreshPayload | null {\n\tconst ring = keyRing(config);\n\tconst kid = (jwt.decode(token, { complete: true }) as { header?: { kid?: unknown } } | null)?.header?.kid;\n\t// A kid names exactly one secret; a token without one (signed before key\n\t// ids) is tried against each. An unknown kid verifies against nothing.\n\tconst candidates = typeof kid === 'string' ? ring.filter((s) => keyIdOf(s) === kid) : ring;\n\tfor (const secret of candidates) {\n\t\ttry {\n\t\t\treturn jwt.verify(token, secret) as IAccessPayload | IRefreshPayload;\n\t\t} catch {\n\t\t\t// wrong key, bad signature or expired — try the next candidate\n\t\t}\n\t}\n\treturn null;\n}\n\n/** A jsonwebtoken-style duration ('90d', '12h', '30m', '45s', '2w', '1y') in ms; null if unreadable. */\nexport function durationMs(value: string | undefined): number | null {\n\tconst m = /^\\s*(\\d+)\\s*(ms|s|m|h|d|w|y)?\\s*$/i.exec(value ?? '');\n\tif (!m) return null;\n\tconst unit = (m[2] ?? 's').toLowerCase();\n\tconst scale: Record<string, number> = { ms: 1, s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5, y: 31_557_6e5 };\n\treturn Number(m[1]) * (scale[unit] ?? 1e3);\n}\n\nconst warnedDurations = new Set<string>();\nfunction validDuration(value: string | undefined, fallback: string, name: string): string {\n\tif (value === undefined || value === null || value === '') return fallback;\n\tif (durationMs(String(value)) !== null) return String(value);\n\tconst key = `${name}=${String(value)}`;\n\tif (!warnedDurations.has(key)) {\n\t\twarnedDurations.add(key);\n\t\tconsole.warn(`[auth] ${name} ${JSON.stringify(value)} is not a duration (e.g. '90d', '1h') — using ${fallback}`);\n\t}\n\treturn fallback;\n}\n","import type { LocationResolver } from './services/request-location';\nimport type { IAuthRateLimitConfig } from './services/rate-limit';\nimport type { ClientKind, ISessionPolicy } from './services/session-policy';\nexport const DEFAULT_VERIFICATION_COOLDOWN = 5 * 60 * 1000; // 5 minutes\n// How long a device stays signed in WITHOUT being used (sliding: every refresh\n// extends it). Messaging apps effectively never sign an idle device out;\n// security comes from revocation (logged-in devices, reuse detection), not\n// from expiry. Was 7d. docs/SESSION-DESIGN.md, Phase 3.\nexport const DEFAULT_SESSION_DURATION = '90d';\n// Access tokens are refreshed silently, so they can be short: a stolen one is\n// useful for at most this long even while its session lives. Was 24h.\nexport const DEFAULT_ACCESS_TOKEN_DURATION = '1h';\n\n// Boot-time only — never resolvable at runtime\nexport interface IAuthSecrets {\n\tjwtSecret: string;\n\t// Previous signing secrets, still accepted for VERIFYING tokens while they\n\t// age out — so rotating jwtSecret signs nobody out. Rotation: move the old\n\t// secret here, set the new jwtSecret, deploy; remove it after the longest\n\t// session lifetime. Tokens carry a key id, so each is checked against one key.\n\tjwtPreviousSecrets?: string[];\n\t// Optional 32-byte key (64 hex chars, e.g. `openssl rand -hex 32`) that\n\t// encrypts TOTP secrets at rest (AES-256-GCM). Unset → secrets are stored\n\t// plaintext (backward-compatible). Set it in production; losing it makes\n\t// existing MFA secrets unrecoverable (users must re-enroll).\n\tmfaSecretKey?: string;\n\tgoogle?: {\n\t\tclientId: string;\n\t\tclientSecret: string;\n\t\tredirectUri: string;\n\t};\n\t// Sign in with Apple. Unlike Google, Apple's client secret is not a static\n\t// string — it's a short-lived ES256 JWT minted from a .p8 key at token\n\t// exchange, so the config carries the signing material, not a secret.\n\tapple?: {\n\t\t// Services ID (Sign in with Apple identifier) — the `aud` for the WEB\n\t\t// redirect flow and the `sub` of the client-secret JWT.\n\t\tclientId: string;\n\t\t// Apple Developer Team ID — the `iss` of the client-secret JWT.\n\t\tteamId: string;\n\t\t// Key ID of the .p8 signing key — the `kid` header of the client secret.\n\t\tkeyId: string;\n\t\t// Contents of the .p8 private key (PEM, `-----BEGIN PRIVATE KEY----- …`).\n\t\tprivateKey: string;\n\t\t// Web form_post callback URL registered with Apple.\n\t\tredirectUri: string;\n\t\t// iOS bundle identifiers whose NATIVE identityToken we accept. Native Sign\n\t\t// in with Apple mints id_tokens with `aud` = the app's bundle id (NOT the\n\t\t// Services ID), so each must be allow-listed for POST /auth/apple/native.\n\t\tnativeClientIds?: string[];\n\t};\n}\n\n// Behavioral — safe to expose to admin dashboard\nexport interface IAuthRuntimeConfig {\n\t// Idle timeout: a session unused this long ends (sliding). Default 90d.\n\tsessionDuration?: string;\n\t// Optional absolute cap: a session this old ends at its next refresh, however\n\t// active — the user signs in again (e.g. '365d'). Unset: no cap.\n\tsessionMaxAge?: string;\n\t// Lifetimes per platform (X-Client-Kind at sign-in: mobile | desktop | web),\n\t// over the shared values above. Presets apply when nothing is set — see\n\t// services/session-policy.ts for the full order.\n\tsessionPolicies?: Partial<Record<ClientKind, ISessionPolicy>>;\n\tverificationCooldown?: number;\n\tmfa?: boolean;\n\trequireVerification?: boolean;\n}\n\n// Type-checked: adding/renaming a field in IAuthRuntimeConfig breaks this at compile time\n// Per-platform keys: sessionPolicyConfigKeys(kind). readAuthRuntimeConfig reads them all.\nexport const AUTH_CONFIG_KEYS: Record<Exclude<keyof IAuthRuntimeConfig, 'sessionPolicies'>, string> = {\n\tsessionDuration: 'auth.session.duration',\n\tsessionMaxAge: 'auth.session.max_age',\n\tverificationCooldown: 'auth.verification.cooldown',\n\tmfa: 'auth.mfa.enabled',\n\trequireVerification: 'auth.verification.required',\n} as const;\n\nexport const MESSAGE_KEYS = {\n\temailRegistration: 'email-registration',\n\temailVerification: 'email-verification',\n\tpasswordReset: 'password-reset',\n\tphoneOtp: 'phone-otp',\n\tmfaEnabled: 'mfa-enabled',\n\tmfaDisabled: 'mfa-disabled',\n\tmfaBackupCodesRegenerated: 'mfa-backup-codes-regenerated',\n\temailChanged: 'email-changed',\n\tphoneChanged: 'phone-changed',\n\t// Changing HOW an account can be signed into is a security event for the\n\t// account's owner, who may not be the person doing it. Both directions are\n\t// notified for the same reason password changes are.\n\t// A password was REVOKED because the account it sat on had never been\n\t// verified and an OAuth identity proved ownership of the mailbox. The\n\t// recipient is now known to own that address, so this reaches the right\n\t// person — and it is the only way they learn a password stopped working.\n\tpasswordRevoked: 'password-revoked',\n\t// Welcome for an account CREATED by an OAuth sign-in. Distinct from\n\t// emailRegistration, which carries a verification PIN — an OAuth signup\n\t// arrives already verified by the provider and has nothing to confirm.\n\toauthRegistration: 'oauth-registration',\n\toauthLinked: 'oauth-linked',\n\toauthUnlinked: 'oauth-unlinked',\n} as const;\n\nexport type AuthMessageKey = (typeof MESSAGE_KEYS)[keyof typeof MESSAGE_KEYS];\n\nexport const EVENT_KEYS = {\n\tuserRegistered: 'fonderie.user.registered',\n\tuserDeleted: 'fonderie.user.deleted',\n\t// Emitted by purgeSoftDeletedUsers (given a bus) once the row is hard-deleted.\n\tuserPurged: 'fonderie.user.purged',\n\temailVerified: 'fonderie.user.email_verified',\n\tpasswordChanged: 'fonderie.user.password_changed',\n\t// Sessions the server revoked: { userId, sids (null = all), reason }. Reaches\n\t// the user's devices live (@fonderie/sse) so a revoked device signs out at\n\t// once. Reasons: 'terminated' (signed out from the devices list),\n\t// 'password-changed', 'admin', 'refresh-reuse' (theft signal).\n\tsessionRevoked: 'fonderie.session.revoked',\n} as const;\n\nexport type AuthEventKey = (typeof EVENT_KEYS)[keyof typeof EVENT_KEYS];\n\nexport interface IAuthConfig extends IAuthSecrets, IAuthRuntimeConfig {\n\t// Optional resolver: where is this request from? Auth calls it when it\n\t// records an auth event — every login attempt, every registration, and every\n\t// new session — and stores the result on that row (country / region / city\n\t// / time zone, plus network facts if the resolver knows them). Called at\n\t// most once per request. Absent ⇒ no location, exactly as before. The result\n\t// is sanitized and bounded; a resolver that throws or takes longer than\n\t// 500 ms leaves the row without a location and never fails the request.\n\t// In-process resolvers never approach that; the cap exists for a DB-backed\n\t// or hosted-API resolver (richer data: ISP, ASN, proxy/VPN).\n\t// On Vercel/Cloudflare: ({ headers }) => geoFromHeaders(headers, { trust })\n\t// from @fonderie/geo — zero infrastructure.\n\tlocation?: LocationResolver;\n\t// Adds the Secure attribute to auth cookies. Defaults to\n\t// NODE_ENV === 'production'; set explicitly when that heuristic is wrong.\n\tsecureCookies?: boolean;\n\t// Brute-force protection — ON by default, backed by the module's own\n\t// store adapter (distributed-correct across instances with zero config).\n\t// Inject a store (e.g. RedisStore) for high-throughput deployments,\n\t// override individual rules, or set false to disable entirely.\n\trateLimit?: IAuthRateLimitConfig | false;\n\t// Access-token lifetime (jsonwebtoken duration string). Default '1h'.\n\t// Access tokens are session-bound and die on logout / rotation /\n\t// password change regardless of this value; shorten it to bound the\n\t// window of a stolen token whose session is still alive.\n\taccessTokenDuration?: string;\n\tproviders: ('email' | 'phone' | 'google' | 'github' | 'apple')[];\n\tappName?: string;\n\t// Base URL for the password-reset LINK. When set, forgot-password emits a\n\t// ready-built `resetUrl` (base + `token=<high-entropy token>`) in the\n\t// notification payload, so a template can offer a click-to-reset link\n\t// instead of only the 6-digit code. Unset → resetUrl is '' and only the\n\t// pin flows (unchanged behaviour). The token backs POST /auth/email/reset\n\t// with `{ token, password }`.\n\tpasswordResetUrl?: string;\n\tresolve?: (ctx: { meta: Record<string, unknown> }) => Partial<IAuthRuntimeConfig>;\n\t// Override the HTTP path (and optionally method) of any auth route, keyed by a\n\t// stable id. Lets an app match an existing frontend's contract without a\n\t// gateway/shim, e.g. `{ forgotPassword: '/auth/forgot-password', updateProfile:\n\t// { method: 'PATCH', path: '/users/me' } }`. A bare string overrides the path;\n\t// an object can also change the method. Unset routes keep their defaults.\n\troutes?: Partial<Record<AuthRouteId, AuthRouteOverride>>;\n\t// Migrating an existing app onto Fonderie auth? Fonderie stores bcrypt\n\t// hashes, so a user imported with a foreign hash (argon2, scrypt, pbkdf2,\n\t// a framework's format, …) can't log in via the built-in bcrypt check.\n\t// Provide `legacyVerify` to validate that foreign hash on login; on the\n\t// first successful login Fonderie transparently re-stores the password as\n\t// bcrypt (rehash-on-login), so the legacy verifier is only ever hit once\n\t// per migrated user. Return true iff `plain` matches `hash`. If the imported\n\t// hashes are already bcrypt, you don't need this — the built-in check\n\t// accepts them and re-stores at the current cost factor automatically.\n\tlegacyVerify?: (plain: string, hash: string) => boolean | Promise<boolean>;\n\t// SAR export contributors from other modules (see IDataExportContributor).\n\tdataExportContributors?: IDataExportContributor[];\n}\n\n// A module's contribution to the per-user data export (SAR). The app wires\n// these (e.g. from @fonderie/workspaces) so auth can aggregate data owned by\n// other packages without importing them.\nexport interface IDataExportContributor {\n\tname: string;\n\tcollect: (userId: string) => Promise<unknown> | unknown;\n}\n\n// Stable ids for every auth route, for the `routes` path/method override map.\nexport type AuthRouteId =\n\t| 'providers' | 'unlinkOauth'\n\t| 'register' | 'login' | 'refresh'\n\t| 'forgotPassword' | 'resetPassword'\n\t| 'verifyEmail' | 'sendVerification'\n\t| 'logout'\n\t| 'me' | 'updateProfile' | 'updatePreferences' | 'updateEmail' | 'updatePhone' | 'changePassword' | 'deleteMe' | 'exportMe'\n\t| 'loginHistory' | 'listSessions' | 'terminateSession' | 'terminateOtherSessions'\n\t| 'mfaSetup' | 'mfaVerify' | 'mfaDisable' | 'mfaBackupCodes';\n\nexport type AuthRouteOverride = string | { method?: string; path?: string };\n\n/** Payload of fonderie.session.revoked. */\nexport interface ISessionRevokedEvent {\n\tuserId: string;\n\t/** The revoked sessions' sids; null = every session of the user. */\n\tsids: Array<string | null> | null;\n\treason: 'terminated' | 'password-changed' | 'admin' | 'refresh-reuse' | 'account-deleted';\n}\n","import type { IStoreAdapter } from '@fonderie/store';\nimport type { IUser } from '../types';\n\nexport interface IUserUpdateFields {\n\tfirstName?: string;\n\tlastName?: string;\n\tphoneNumber?: string;\n\tavatarUrl?: string;\n\tlocale?: string;\n\ttimezone?: string;\n\tpreferences?: unknown;\n}\n\nconst USER_COLUMNS = `\n\tid,\n\temail,\n\tpassword_hash AS \"passwordHash\",\n\tfirst_name AS \"firstName\",\n\tlast_name AS \"lastName\",\n\tphone,\n\tprofile_image_url AS \"profileImageUrl\",\n\tlocale,\n\ttimezone,\n\tis_active AS \"isActive\",\n\tlast_login AS \"lastLogin\",\n\tpreferences,\n\tsuspended,\n\twhitelist,\n\tip_whitelist AS \"ipWhitelist\",\n\tmfa_enabled AS \"mfaEnabled\",\n\temail_verified_at AS \"emailVerifiedAt\",\n\tprovider,\n\tdeleted_at AS \"deletedAt\",\n\tcreated_at AS \"createdAt\",\n\tupdated_at AS \"updatedAt\"\n`;\n\nexport class UserModel {\n\tconstructor(private store: IStoreAdapter) {}\n\n\t// One page of users, newest first. Keyset-paginated on (created_at, id) —\n\t// the same cursor contract as login history and the audit log. The +1\n\t// over-fetch that detects a next page lives here so no outer clamp can\n\t// shave it off. Soft-deleted rows are excluded, as in every other finder.\n\tasync list(query: IUserListQuery = {}): Promise<IUserPage> {\n\t\tconst limit = Math.min(query.limit ?? 50, MAX_LIST_LIMIT);\n\t\tconst params: unknown[] = [];\n\t\tconst where: string[] = [query.deleted ? 'deleted_at IS NOT NULL' : 'deleted_at IS NULL'];\n\n\t\tif (query.cursor) {\n\t\t\tparams.push(query.cursor.createdAt, query.cursor.id);\n\t\t\twhere.push(`(created_at, id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);\n\t\t}\n\n\t\tparams.push(limit + 1);\n\t\tconst rows = await this.store.query<IUser & { createdAtRaw?: string }>(\n\t\t\t`SELECT ${USER_COLUMNS}, created_at::text AS \"createdAtRaw\"\n\t\t\t FROM fonderie_users\n\t\t\t WHERE ${where.join(' AND ')}\n\t\t\t ORDER BY created_at DESC, id DESC\n\t\t\t LIMIT $${params.length}`,\n\t\t\tparams,\n\t\t);\n\t\treturn { users: rows.slice(0, limit), hasMore: rows.length > limit };\n\t}\n\n\t// For operator views only: a soft-deleted account still resolves, with\n\t// deletedAt set, so the console can say \"deleted on …\" instead of \"no such\n\t// user\". Nothing that authenticates or acts on an account may use this.\n\tasync findByIdIncludingDeleted(id: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findById(id: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE id = $1 AND deleted_at IS NULL`,\n\t\t\t[id],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByEmail(email: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE email = $1 AND deleted_at IS NULL`,\n\t\t\t[email],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findByPhone(phone: string): Promise<IUser | null> {\n\t\tconst [row] = await this.store.query<IUser>(\n\t\t\t`SELECT ${USER_COLUMNS} FROM fonderie_users WHERE phone = $1 AND deleted_at IS NULL`,\n\t\t\t[phone],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync findOrCreateByPhone(\n\t\tphone: string,\n\t\tfirstName: string | null = null,\n\t\tlastName: string | null = null,\n\t\t// Only on insert: an existing account keeps the locale it chose.\n\t\tlocale: string | null = null,\n\t): Promise<{ id: string }> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (phone, first_name, last_name, locale)\n\t\t\tVALUES ($1, $2, $3, COALESCE($4, 'en-US'))\n\t\t\tON CONFLICT (phone) DO UPDATE\n\t\t\tSET first_name = COALESCE(EXCLUDED.first_name, fonderie_users.first_name),\n\t\t\t last_name = COALESCE(EXCLUDED.last_name, fonderie_users.last_name),\n\t\t\t updated_at = now()\n\t\t\tRETURNING id`,\n\t\t\t[phone, firstName, lastName, locale],\n\t\t);\n\t\treturn row!;\n\t}\n\n\tasync create(\n\t\temail: string,\n\t\tpasswordHash: string,\n\t\tfirstName: string | null,\n\t\tlastName: string | null,\n\t\t// The sign-up language, else the app's system locale; null keeps the\n\t\t// column default (en-US) for callers that predate locales.\n\t\tlocale: string | null = null,\n\t): Promise<{ id: string } | null> {\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`INSERT INTO fonderie_users (email, password_hash, first_name, last_name, locale)\n\t\t\tVALUES ($1, $2, $3, $4, COALESCE($5, 'en-US'))\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName, locale],\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync update(id: string, fields: IUserUpdateFields): Promise<{ id: string } | null> {\n\t\tconst columnMap: Record<string, string> = {\n\t\t\tfirstName: 'first_name',\n\t\t\tlastName: 'last_name',\n\t\t\tphoneNumber: 'phone',\n\t\t\tavatarUrl: 'profile_image_url',\n\t\t\tlocale: 'locale',\n\t\t\ttimezone: 'timezone',\n\t\t\tpreferences: 'preferences',\n\t\t};\n\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tfor (const [key, col] of Object.entries(columnMap)) {\n\t\t\tif ((fields as Record<string, unknown>)[key] !== undefined) {\n\t\t\t\tvalues.push((fields as Record<string, unknown>)[key]);\n\t\t\t\tsets.push(`${col} = $${values.length}`);\n\t\t\t}\n\t\t}\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\t/**\n\t * Clear the OAuth provider, but ONLY when the account can still be signed\n\t * into afterwards.\n\t *\n\t * An account created through Google has no password. Unlinking it without\n\t * checking would not be \"removing a sign-in method\" — it would be locking\n\t * the owner out permanently, which is account deletion wearing a friendlier\n\t * label. The guard lives in the WHERE clause so the check and the write are\n\t * one atomic statement: a password cannot be removed between them.\n\t *\n\t * Returns false when the account has no password (nothing was changed) so\n\t * the caller can say why rather than reporting a silent success.\n\t */\n\tasync clearProvider(id: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET provider = NULL, provider_id = NULL\n\t\t\t WHERE id = $1\n\t\t\t AND password_hash IS NOT NULL\n\t\t\t AND provider IS NOT NULL\n\t\t\t RETURNING id`,\n\t\t\t[id],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\tasync updatePassword(id: string, passwordHash: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET password_hash = $1 WHERE id = $2`, [\n\t\t\tpasswordHash,\n\t\t\tid,\n\t\t]);\n\t}\n\n\tasync markEmailVerified(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email_verified_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\t// The operator's lock: login, refresh and the session middleware all refuse a\n\t// suspended user. Returns false when there is no such user.\n\tasync setSuspended(id: string, suspended: boolean): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET suspended = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL RETURNING id`,\n\t\t\t[id, suspended],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\tasync softDelete(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET deleted_at = now(), updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync saveMfaSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_secret = $1 WHERE id = $2`, [secret, id]);\n\t}\n\n\tasync saveMfaPendingSecret(id: string, secret: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret_pending = $1, mfa_secret_pending_expires_at = now() + interval '15 minutes'\n\t\t\t WHERE id = $2`,\n\t\t\t[secret, id],\n\t\t);\n\t}\n\n\tasync getMfaPendingSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret_pending: string | null }>(\n\t\t\t`SELECT mfa_secret_pending FROM fonderie_users\n\t\t\t WHERE id = $1 AND mfa_secret_pending_expires_at > now()`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret_pending ?? null;\n\t}\n\n\tasync confirmMfaSecret(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users\n\t\t\t SET mfa_secret = mfa_secret_pending,\n\t\t\t mfa_secret_pending = NULL,\n\t\t\t mfa_secret_pending_expires_at = NULL,\n\t\t\t mfa_enabled = true,\n\t\t\t updated_at = now()\n\t\t\t WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync enableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(`UPDATE fonderie_users SET mfa_enabled = true WHERE id = $1`, [id]);\n\t}\n\n\tasync disableMfa(id: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET mfa_enabled = false, mfa_secret = NULL, updated_at = now() WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t}\n\n\tasync updateEmail(id: string, email: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET email = $1, email_verified_at = NULL, updated_at = now() WHERE id = $2`,\n\t\t\t[email.toLowerCase().trim(), id],\n\t\t);\n\t}\n\n\tasync updatePhone(id: string, phone: string): Promise<void> {\n\t\tawait this.store.query(\n\t\t\t`UPDATE fonderie_users SET phone = $1, updated_at = now() WHERE id = $2`,\n\t\t\t[phone, id],\n\t\t);\n\t}\n\n\tasync updatePreferences(\n\t\tid: string,\n\t\tfields: {\n\t\t\tlocale?: string;\n\t\t\ttimezone?: string;\n\t\t\tpatch?: Record<string, unknown>;\n\t\t},\n\t): Promise<{ id: string } | null> {\n\t\tconst sets: string[] = [];\n\t\tconst values: unknown[] = [];\n\n\t\tif (fields.locale !== undefined) {\n\t\t\tvalues.push(fields.locale);\n\t\t\tsets.push(`locale = $${values.length}`);\n\t\t}\n\t\tif (fields.timezone !== undefined) {\n\t\t\tvalues.push(fields.timezone);\n\t\t\tsets.push(`timezone = $${values.length}`);\n\t\t}\n\t\tif (fields.patch && Object.keys(fields.patch).length > 0) {\n\t\t\tvalues.push(JSON.stringify(fields.patch));\n\t\t\tsets.push(`preferences = preferences || $${values.length}::jsonb`);\n\t\t}\n\n\t\tif (sets.length === 0) return null;\n\n\t\tvalues.push(id);\n\t\tconst [row] = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_users SET ${sets.join(', ')}, updated_at = now() WHERE id = $${values.length} AND deleted_at IS NULL RETURNING id`,\n\t\t\tvalues,\n\t\t);\n\t\treturn row ?? null;\n\t}\n\n\tasync getMfaSecret(id: string): Promise<string | null> {\n\t\tconst [row] = await this.store.query<{ mfa_secret: string | null }>(\n\t\t\t`SELECT mfa_secret FROM fonderie_users WHERE id = $1`,\n\t\t\t[id],\n\t\t);\n\t\treturn row?.mfa_secret ?? null;\n\t}\n\n\t/**\n\t * Link an OAuth identity to the account owning `email`, creating it if there\n\t * is none.\n\t *\n\t * Returns what HAPPENED, not just who it happened to. Three outcomes hide\n\t * behind one upsert, and callers must tell them apart:\n\t *\n\t * • `inserted` — a brand-new account. Whoever provisions on signup (the\n\t * personal workspace, a wallet, a welcome email) must run, exactly as\n\t * it does for a password registration. This is the case that was\n\t * previously invisible: the upsert returned an id and looked identical\n\t * to a returning user signing in, so OAuth signups silently skipped\n\t * every signup side effect.\n\t * • `previousProvider` differs — an existing account gained, or switched,\n\t * a way to sign in. A security event for the owner.\n\t * • `previousProvider` is the same — an ordinary login. Nothing to do, and\n\t * notifying here would email the user on every single sign-in.\n\t *\n\t * The prior state is read in a CTE of the SAME statement, so it sees the\n\t * snapshot from before the insert. Reading it in a separate query would\n\t * race two concurrent logins into both seeing \"new\".\n\t *\n\t * SECURITY — linking is BY EMAIL, so this merges into whatever row already\n\t * holds the address. If that row was never verified, its password was never\n\t * proven to belong to the mailbox; anyone can register an address they do\n\t * not own. The provider HAS proven ownership. So an unverified password is\n\t * revoked at the moment of linking: without that, linking sets\n\t * email_verified_at and the earlier registrant is left holding a working\n\t * password on an account now treated as verified.\n\t *\n\t * The legitimate user loses nothing but a password reset — they own the\n\t * mailbox. Someone who does not own it cannot receive that mail.\n\t */\n\tasync upsertByProvider(\n\t\temail: string,\n\t\tprovider: string,\n\t\tproviderId: string,\n\t): Promise<{\n\t\tid: string;\n\t\tinserted: boolean;\n\t\tpreviousProvider: string | null;\n\t\tclearedUnverifiedPassword: boolean;\n\t} | null> {\n\t\tconst [row] = await this.store.query<{\n\t\t\tid: string;\n\t\t\tinserted: boolean;\n\t\t\tpreviousProvider: string | null;\n\t\t\tclearedUnverifiedPassword: boolean;\n\t\t}>(\n\t\t\t`WITH prior AS (\n\t\t\t\tSELECT provider, email_verified_at, password_hash IS NOT NULL AS had_password\n\t\t\t\t FROM fonderie_users WHERE email = $1\n\t\t\t), upserted AS (\n\t\t\t\tINSERT INTO fonderie_users (email, email_verified_at, provider, provider_id)\n\t\t\t\tVALUES ($1, now(), $2, $3)\n\t\t\t\tON CONFLICT (email) DO UPDATE\n\t\t\t\tSET provider = $2, provider_id = $3,\n\t\t\t\t email_verified_at = COALESCE(fonderie_users.email_verified_at, now()),\n\t\t\t\t -- A password on an UNVERIFIED account is a claim, not a\n\t\t\t\t -- credential: nobody ever proved that mailbox belongs to\n\t\t\t\t -- whoever set it. The provider has now proven it belongs to\n\t\t\t\t -- the person signing in. Drop the unproven claim rather than\n\t\t\t\t -- promote it — otherwise linking marks the account verified\n\t\t\t\t -- and silently hands the earlier registrant a working\n\t\t\t\t -- password on a now-trusted account.\n\t\t\t\t password_hash = CASE\n\t\t\t\t WHEN fonderie_users.email_verified_at IS NULL THEN NULL\n\t\t\t\t ELSE fonderie_users.password_hash\n\t\t\t\t END\n\t\t\t\tRETURNING id\n\t\t\t)\n\t\t\tSELECT upserted.id,\n\t\t\t NOT EXISTS (SELECT 1 FROM prior) AS inserted,\n\t\t\t (SELECT provider FROM prior) AS \"previousProvider\",\n\t\t\t -- True only when a password was actually revoked, so the\n\t\t\t -- caller can tell the owner their sign-in method changed.\n\t\t\t COALESCE((SELECT had_password AND email_verified_at IS NULL FROM prior), false)\n\t\t\t AS \"clearedUnverifiedPassword\"\n\t\t\tFROM upserted`,\n\t\t\t[email, provider, providerId],\n\t\t);\n\t\treturn row ?? null;\n\t}\n}\n\nconst MAX_LIST_LIMIT = 200;\n\nexport interface IUserListQuery {\n\tlimit?: number;\n\tcursor?: { createdAt: string; id: string };\n\t// true ⇒ ONLY soft-deleted accounts (an operator view; every other finder\n\t// excludes them). They stay until the retention purge hard-deletes them.\n\tdeleted?: boolean;\n}\n\nexport interface IUserPage {\n\t// Carries createdAtRaw (created_at::text) so the cursor keeps the\n\t// microsecond precision a Date round-trip would drop.\n\tusers: Array<IUser & { createdAtRaw?: string }>;\n\thasMore: boolean;\n}\n","import { createHash } from 'node:crypto';\n\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\nimport { CLIENT_KINDS, type ClientKind } from '../services/session-policy';\nimport { type IRequestLocation, type LocationResolver, resolveLocation } from '../services/request-location';\n\n// What `fonderie_sessions.token` holds: the SHA-256 of the refresh token, so a\n// database dump contains no working refresh token. Lookups also accept the raw\n// value, for rows written before migration 020 hashed them.\nexport function hashRefreshToken(token: string): string {\n\treturn createHash('sha256').update(token).digest('hex');\n}\n\n/** How long a just-rotated refresh token stays valid: a retry or a race. */\nexport const REFRESH_GRACE_MS = 30_000;\n\nexport interface ISessionRow {\n\tid: string;\n\tuserId: string;\n\tsid: string | null;\n\t/** What the stored `token` column holds now (a hash, or a legacy raw token). */\n\tstored: string;\n\t/** When the device signed in — the start of the absolute cap. */\n\tcreatedAt: Date;\n\t/** The platform declared at sign-in — its lifetimes apply at every refresh. */\n\tclientKind: ClientKind | null;\n}\n\n/** How a presented refresh token relates to the sessions table. */\nexport type RefreshMatch =\n\t| { kind: 'current'; row: ISessionRow }\n\t| { kind: 'grace'; row: ISessionRow }\n\t| { kind: 'reused'; row: ISessionRow }\n\t| { kind: 'unknown' };\n\nexport class SessionModel {\n\t// `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.\n\tconstructor(\n\t\tprivate store: IStoreAdapter,\n\t\tprivate locate?: LocationResolver,\n\t) {}\n\n\tasync create(\n\t\tuserId: string,\n\t\ttoken: string,\n\t\texpiresAt: Date,\n\t\tsid?: string,\n\t\tmeta?: IRequestMeta,\n\t): Promise<void> {\n\t\tconst location =\n\t\t\tthis.locate && meta?.headers\n\t\t\t\t? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers })\n\t\t\t\t: null;\n\t\tawait this.store.query(\n\t\t\t`INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location, last_used_at, client_kind)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7, now(), $8)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[\n\t\t\t\tuserId,\n\t\t\t\thashRefreshToken(token),\n\t\t\t\texpiresAt,\n\t\t\t\tsid ?? null,\n\t\t\t\tmeta?.userAgent ?? null,\n\t\t\t\tmeta?.ipAddress ?? null,\n\t\t\t\tlocation ? JSON.stringify(location) : null,\n\t\t\t\tmeta?.clientKind ?? null,\n\t\t\t],\n\t\t);\n\t}\n\n\tasync delete(token: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE token IN ($1, $2)`, [hashRefreshToken(token), token]);\n\t}\n\n\t/**\n\t * Where a presented refresh token stands: the session's current token, its\n\t * previous one within the grace (a retry or race), its previous one AFTER\n\t * the grace (reuse — a theft signal), or nothing known.\n\t */\n\tasync match(token: string): Promise<RefreshMatch> {\n\t\tconst hash = hashRefreshToken(token);\n\t\tconst [current] = await this.store.query<{ id: string; user_id: string; sid: string | null; token: string; created_at: Date; client_kind: string | null }>(\n\t\t\t`SELECT id, user_id, sid, token, created_at, client_kind FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now() LIMIT 1`,\n\t\t\t[hash, token],\n\t\t);\n\t\tif (current) return { kind: 'current', row: toRow(current) };\n\t\tconst [previous] = await this.store.query<{ id: string; user_id: string; sid: string | null; token: string; created_at: Date; client_kind: string | null; in_grace: boolean }>(\n\t\t\t`SELECT id, user_id, sid, token, created_at, client_kind, (previous_valid_until > now()) AS in_grace\n\t\t\t FROM fonderie_sessions WHERE previous_token_hash = $1 AND expires_at > now() LIMIT 1`,\n\t\t\t[hash],\n\t\t);\n\t\tif (!previous) return { kind: 'unknown' };\n\t\treturn { kind: previous.in_grace ? 'grace' : 'reused', row: toRow(previous) };\n\t}\n\n\t/**\n\t * Rotate a session in place to a new refresh token: same row, same device.\n\t * From the CURRENT token it is optimistic — only if the stored token is\n\t * still the one matched; a concurrent rotation makes it return false and the\n\t * caller re-matches (and now finds the token as the previous one). The old\n\t * token becomes the previous, valid for the grace. From the PREVIOUS token\n\t * (within the grace) it is authorised by that hash and deadline alone, so\n\t * racing retries never starve; the previous and its deadline are kept.\n\t */\n\tasync rotate(\n\t\trow: ISessionRow,\n\t\tpresented: string,\n\t\tnext: { token: string; expiresAt: Date },\n\t\tfrom: 'current' | 'grace',\n\t): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`UPDATE fonderie_sessions\n\t\t\t SET previous_token_hash = CASE WHEN $5 THEN $6 ELSE previous_token_hash END,\n\t\t\t previous_valid_until = CASE WHEN $5 THEN now() + ($7 || ' milliseconds')::interval ELSE previous_valid_until END,\n\t\t\t token = $3,\n\t\t\t expires_at = $4,\n\t\t\t last_used_at = now()\n\t\t\t WHERE id = $1\n\t\t\t AND CASE WHEN $5 THEN token = $2\n\t\t\t ELSE previous_token_hash = $6 AND previous_valid_until > now() END\n\t\t\tRETURNING id`,\n\t\t\t[row.id, row.stored, hashRefreshToken(next.token), next.expiresAt, from === 'current', hashRefreshToken(presented), String(REFRESH_GRACE_MS)],\n\t\t);\n\t\treturn rows.length === 1;\n\t}\n\n\t/** Revoke one session by its row id (any user) — reuse detection, admin. */\n\tasync revokeById(id: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE id = $1`, [id]);\n\t}\n\n\t// Delete the session an access token is bound to (by its sid claim). Lets\n\t// logout revoke the current session without the client resending the refresh\n\t// token — the access token already identifies its session.\n\tasync deleteBySid(sid: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE sid = $1`, [sid]);\n\t}\n\n\t// Revoke every session for a user (e.g. on password change). Access tokens\n\t// bound to these sessions via the sid claim die on their next request.\n\tasync deleteByUser(userId: string): Promise<void> {\n\t\tawait this.store.query(`DELETE FROM fonderie_sessions WHERE user_id = $1`, [userId]);\n\t}\n\n\t// Session metadata for a user (no tokens) — for the data-export / SAR bundle.\n\tasync listByUser(userId: string): Promise<\n\t\tArray<{ id: string; userAgent: string | null; ipAddress: string | null; createdAt: Date; expiresAt: Date }>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id,\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Live sessions for the Active Sessions screen: unexpired only, carrying the\n\t// sid so the caller can flag the current one (row.sid === the JWT's sid).\n\t// No token — that never leaves the server.\n\tasync listLiveByUser(userId: string): Promise<\n\t\tArray<{\n\t\t\tid: string;\n\t\t\tsid: string | null;\n\t\t\tuserAgent: string | null;\n\t\t\tipAddress: string | null;\n\t\t\tlocation: IRequestLocation | null;\n\t\t\tclientKind: string | null;\n\t\t\tcreatedAt: Date;\n\t\t\texpiresAt: Date;\n\t\t}>\n\t> {\n\t\treturn this.store.query(\n\t\t\t`SELECT id, sid, client_kind AS \"clientKind\",\n\t\t\t user_agent AS \"userAgent\",\n\t\t\t ip_address AS \"ipAddress\",\n\t\t\t location,\n\t\t\t created_at AS \"createdAt\",\n\t\t\t expires_at AS \"expiresAt\"\n\t\t\t FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND expires_at > now()\n\t\t\t ORDER BY created_at DESC`,\n\t\t\t[userId],\n\t\t);\n\t}\n\n\t// Terminate one session by row id, scoped to its owner (a user can never\n\t// delete another user's session). Returns whether a row was removed.\n\tasync terminateById(userId: string, id: string): Promise<boolean> {\n\t\treturn (await this.terminateByIdReturningSid(userId, id)) !== undefined;\n\t}\n\n\t/** Terminate one of the user's sessions; its sid (null if unbound), or undefined if none was removed. */\n\tasync terminateByIdReturningSid(userId: string, id: string): Promise<string | null | undefined> {\n\t\tconst rows = await this.store.query<{ sid: string | null }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING sid`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length ? (rows[0]?.sid ?? null) : undefined;\n\t}\n\n\t// Terminate all of a user's sessions except the current one (by sid).\n\t// Returns the number of sessions removed.\n\tasync terminateOthers(userId: string, keepSid: string): Promise<number> {\n\t\treturn (await this.terminateOthersReturningSids(userId, keepSid)).length;\n\t}\n\n\t/** Terminate all of the user's sessions but `keepSid`; the sids removed. */\n\tasync terminateOthersReturningSids(userId: string, keepSid: string): Promise<Array<string | null>> {\n\t\tconst rows = await this.store.query<{ sid: string | null }>(\n\t\t\t`DELETE FROM fonderie_sessions\n\t\t\t WHERE user_id = $1 AND (sid IS DISTINCT FROM $2)\n\t\t\t RETURNING sid`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.map((r) => r.sid);\n\t}\n\n\tasync exists(token: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE token IN ($1, $2) AND expires_at > now()`,\n\t\t\t[hashRefreshToken(token), token],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n\n\t// Liveness check for session-bound access tokens (by the sid claim).\n\tasync aliveBySid(sid: string): Promise<boolean> {\n\t\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`SELECT id FROM fonderie_sessions WHERE sid = $1 AND expires_at > now()`,\n\t\t\t[sid],\n\t\t);\n\t\treturn rows.length > 0;\n\t}\n}\n\nfunction toRow(r: { id: string; user_id: string; sid: string | null; token: string; created_at?: Date; client_kind?: string | null }): ISessionRow {\n\tconst kind = (CLIENT_KINDS as readonly string[]).includes(r.client_kind ?? '') ? (r.client_kind as ClientKind) : null;\n\treturn { id: r.id, userId: r.user_id, sid: r.sid, stored: r.token, createdAt: r.created_at ? new Date(r.created_at) : new Date(), clientKind: kind };\n}\n","// Session lifetimes per platform (docs/SESSION-DESIGN.md, Phase 3c).\n//\n// A phone in someone's pocket and a browser on a shared computer should not\n// stay signed in equally long. The client declares its platform at SIGN-IN\n// (header X-Client-Kind: mobile | desktop | web); it is recorded on the\n// session and used at every refresh — a client cannot promote itself later.\n//\n// Each lifetime resolves, first match wins:\n// 1. the console, for this platform auth.session.duration.web\n// 2. the console, shared auth.session.duration\n// 3. the app's code, for this platform sessionPolicies.web\n// 4. the app's code, shared sessionDuration\n// 5. the preset for this platform SESSION_POLICY_PRESETS\n// 6. the library default 90 d idle / 1 h access\n// A client that declares nothing gets 2 → 4 → 6: today's behaviour.\n\nimport type { IAuthConfig, IAuthRuntimeConfig } from '../config';\n\nexport const CLIENT_KINDS = ['mobile', 'desktop', 'web'] as const;\nexport type ClientKind = (typeof CLIENT_KINDS)[number];\n\nexport interface ISessionPolicy {\n\t/** Idle timeout, sliding (e.g. '90d'). */\n\tsessionDuration?: string;\n\t/** Absolute cap from sign-in (e.g. '365d'). */\n\tsessionMaxAge?: string;\n\t/** Access-token lifetime (e.g. '1h'). */\n\taccessTokenDuration?: string;\n}\n\n/** Built-in defaults per platform; every value is overridable (see the order above). */\nexport const SESSION_POLICY_PRESETS: Readonly<Record<ClientKind, ISessionPolicy>> = {\n\t// Personal device; the token lives in the Keychain / Keystore.\n\tmobile: { sessionDuration: '90d', sessionMaxAge: '365d' },\n\t// Personal, but often a shared workstation.\n\tdesktop: { sessionDuration: '30d', sessionMaxAge: '180d' },\n\t// Shared and public computers; cookies.\n\tweb: { sessionDuration: '14d', sessionMaxAge: '90d' },\n};\n\n/** The platform a request declares, or null (undeclared or unknown values). */\nexport function clientKindOf(headers: Headers): ClientKind | null {\n\tconst v = headers.get('x-client-kind')?.trim().toLowerCase();\n\treturn (CLIENT_KINDS as readonly string[]).includes(v ?? '') ? (v as ClientKind) : null;\n}\n\nconst FIELDS = ['sessionDuration', 'sessionMaxAge', 'accessTokenDuration'] as const;\n\n/**\n * The config to issue tokens with for this platform: the app's config, its\n * runtime (console) values, and the lifetimes resolved in the order above.\n */\nexport function configForClient(\n\tconfig: IAuthConfig,\n\truntime: Partial<IAuthRuntimeConfig> | undefined,\n\tkind: ClientKind | null,\n): IAuthConfig {\n\tconst rt = (runtime ?? {}) as Partial<IAuthRuntimeConfig> & ISessionPolicy;\n\tconst code = config as IAuthConfig & ISessionPolicy;\n\tconst out: IAuthConfig & ISessionPolicy = { ...config, ...rt };\n\tfor (const field of FIELDS) {\n\t\tconst value =\n\t\t\t(kind ? rt.sessionPolicies?.[kind]?.[field] : undefined) ??\n\t\t\trt[field] ??\n\t\t\t(kind ? code.sessionPolicies?.[kind]?.[field] : undefined) ??\n\t\t\tcode[field] ??\n\t\t\t(kind ? SESSION_POLICY_PRESETS[kind][field] : undefined);\n\t\tif (value === undefined) delete out[field];\n\t\telse out[field] = value;\n\t}\n\treturn out;\n}\n\n/** Console keys for one platform's lifetimes. */\nexport function sessionPolicyConfigKeys(kind: ClientKind): Record<keyof ISessionPolicy, string> {\n\treturn {\n\t\tsessionDuration: `auth.session.duration.${kind}`,\n\t\tsessionMaxAge: `auth.session.max_age.${kind}`,\n\t\taccessTokenDuration: `auth.access.duration.${kind}`,\n\t};\n}\n\n/**\n * Read auth's runtime config from the app's config store, safely: a key that\n * is not set yields undefined — never the text \"undefined\" that\n * `String(getConfig(...)) || undefined` produces (which once made every refresh\n * fail). Use it as `resolve: (ctx) => readAuthRuntimeConfig((key) => getConfig(ctx, key, undefined))`.\n */\nexport function readAuthRuntimeConfig(read: (key: string) => unknown): Partial<IAuthRuntimeConfig> {\n\tconst text = (key: string) => {\n\t\tconst v = read(key);\n\t\treturn typeof v === 'string' && v.trim() ? v.trim() : undefined;\n\t};\n\tconst bool = (key: string) => {\n\t\tconst v = read(key);\n\t\tif (typeof v === 'boolean') return v;\n\t\tif (typeof v === 'string' && ['true', 'false'].includes(v.trim().toLowerCase())) return v.trim().toLowerCase() === 'true';\n\t\treturn undefined;\n\t};\n\tconst num = (key: string) => {\n\t\tconst v = read(key);\n\t\tconst n = typeof v === 'number' ? v : typeof v === 'string' && v.trim() ? Number(v) : NaN;\n\t\treturn Number.isFinite(n) ? n : undefined;\n\t};\n\tconst out: Partial<IAuthRuntimeConfig> & ISessionPolicy = {};\n\tconst set = <K extends string>(k: K, v: unknown) => {\n\t\tif (v !== undefined) (out as Record<string, unknown>)[k] = v;\n\t};\n\tset('sessionDuration', text('auth.session.duration'));\n\tset('sessionMaxAge', text('auth.session.max_age'));\n\tset('accessTokenDuration', text('auth.access.duration'));\n\tset('verificationCooldown', num('auth.verification.cooldown'));\n\tset('mfa', bool('auth.mfa.enabled'));\n\tset('requireVerification', bool('auth.verification.required'));\n\tconst policies: Partial<Record<ClientKind, ISessionPolicy>> = {};\n\tfor (const kind of CLIENT_KINDS) {\n\t\tconst keys = sessionPolicyConfigKeys(kind);\n\t\tconst policy: ISessionPolicy = {};\n\t\tfor (const field of FIELDS) {\n\t\t\tconst v = text(keys[field]);\n\t\t\tif (v !== undefined) policy[field] = v;\n\t\t}\n\t\tif (Object.keys(policy).length) policies[kind] = policy;\n\t}\n\tif (Object.keys(policies).length) out.sessionPolicies = policies;\n\treturn out;\n}\n","// Optional location enrichment for auth events (login history, registrations,\n// sessions).\n//\n// Auth never resolves a location itself and imports no geo code: the app hands\n// in a resolver (IAuthConfig.location). Typical choices — the platform's\n// edge headers via @fonderie/geo's geoFromHeaders (zero infrastructure), a\n// self-hosted table, or a hosted IP API that also knows the network (ISP, ASN,\n// proxy/VPN, hosting). Field names match @fonderie/geo's GeoLocation, so its\n// result can be returned as-is.\n//\n// Whatever the resolver returns is treated as untrusted input: every field is\n// type-checked and bounded before it is stored, and a resolver that throws or\n// stalls costs the login nothing — the row is written without a location.\n\n/** Where a login attempt came from. Every field is optional; unknown is null. */\nexport interface IRequestLocation {\n\tcountry?: string | null; // ISO-3166-1 alpha-2\n\tcountryName?: string | null;\n\tsubdivision?: string | null; // ISO-3166-2 region part (QC, CA, ENG)\n\tsubdivisionName?: string | null;\n\tcity?: string | null;\n\t/** Postal / ZIP code as the provider reports it (94043, K1A 0B1, SW1A). Approximate. */\n\tpostalCode?: string | null;\n\tcontinent?: string | null; // two-letter continent code\n\ttimeZone?: string | null; // IANA name\n\tlatitude?: number | null;\n\tlongitude?: number | null;\n\t/** How far off the point may be, in kilometres (MaxMind's accuracy_radius).\n\t * Lets a UI say \"near Mountain View (±20 km)\" instead of implying precision. */\n\taccuracyRadius?: number | null;\n\t/** A stable, language-neutral place key (MaxMind/GeoNames id). Lets a reader\n\t * see the stored place in their own language later, and lets an app map it\n\t * to its own regions. Names above are a snapshot; this is the reference. */\n\tgeonameId?: number | null;\n\t/** Network facts — only resolvers backed by an IP-intelligence API know these. */\n\tisp?: string | null;\n\torg?: string | null;\n\tasn?: string | null; // e.g. \"AS15169\"\n\tmobile?: boolean | null;\n\tproxy?: boolean | null;\n\thosting?: boolean | null;\n}\n\nexport interface ILocationRequest {\n\t/** The resolved client IP (adapter-resolved, proxy-aware), or null. */\n\tip: string | null;\n\t/** The incoming request's headers — for platform geolocation headers. */\n\theaders: Headers;\n}\n\nexport type LocationResolver = (\n\treq: ILocationRequest,\n) => IRequestLocation | null | undefined | Promise<IRequestLocation | null | undefined>;\n\n// How long auth waits for the resolver before writing the row without a\n// location. Internal, deliberately not exported. It is zero-cost for in-process\n// resolvers (edge headers return in microseconds) and exists for the ones that\n// are not: @fonderie/geo's PostgresGeoProvider does a DB query that can hang\n// on an exhausted pool, and an app may choose a hosted IP-intelligence API for\n// richer data (ISP, ASN, proxy/VPN, accuracy radius). A legitimate lookup\n// answers in a few to ~150 ms; the session write that triggers resolution is\n// on the login path, so the cap bounds the worst-case delay a slow provider\n// can add to a sign-in.\nconst LOCATION_TIMEOUT_MS = 500;\n\nconst TEXT_MAX = 128;\n\nfunction str(v: unknown, re?: RegExp): string | null {\n\tif (typeof v !== 'string') return null;\n\tconst t = v.trim();\n\tif (t.length === 0 || t.length > TEXT_MAX) return null;\n\treturn re && !re.test(t) ? null : t;\n}\nfunction upperCode(v: unknown, re: RegExp): string | null {\n\tconst t = str(v, re);\n\treturn t ? t.toUpperCase() : null;\n}\nfunction bool(v: unknown): boolean | null {\n\treturn typeof v === 'boolean' ? v : null;\n}\n// Two decimals ≈ 1 km: plenty for \"which city\", and a login history should not\n// keep more precision than the city it names.\nfunction coord(v: unknown, limit: number): number | null {\n\tif (typeof v !== 'number' || !Number.isFinite(v) || Math.abs(v) > limit) return null;\n\treturn Math.round(v * 100) / 100;\n}\n\n// Kilometres, a positive whole number. MaxMind's values run 1–1000; anything\n// beyond the planet's half-circumference is not a radius.\nfunction radius(v: unknown): number | null {\n\tif (typeof v !== 'number' || !Number.isFinite(v) || v <= 0 || v > 20_000) return null;\n\treturn Math.round(v);\n}\n\n// GeoNames ids are positive integers well inside 2^31.\nfunction placeId(v: unknown): number | null {\n\tconst n = typeof v === 'string' && /^\\d{1,10}$/.test(v) ? Number(v) : v;\n\treturn typeof n === 'number' && Number.isInteger(n) && n > 0 && n <= 2_147_483_647 ? n : null;\n}\n\n/** Keep only well-formed, bounded fields. Returns null when nothing survives. */\nexport function sanitizeLocation(input: unknown): IRequestLocation | null {\n\tif (!input || typeof input !== 'object') return null;\n\tconst i = input as Record<string, unknown>;\n\tconst out: IRequestLocation = {\n\t\tcountry: upperCode(i['country'], /^[A-Za-z]{2}$/),\n\t\tcountryName: str(i['countryName']),\n\t\tsubdivision: upperCode(i['subdivision'], /^[A-Za-z0-9]{1,3}$/),\n\t\tsubdivisionName: str(i['subdivisionName']),\n\t\tcity: str(i['city']),\n\t\tpostalCode: upperCode(i['postalCode'], /^[A-Za-z0-9][A-Za-z0-9 -]{0,11}$/),\n\t\tcontinent: upperCode(i['continent'], /^[A-Za-z]{2}$/),\n\t\ttimeZone: str(i['timeZone'], /^(UTC|[A-Za-z_]+(\\/[A-Za-z0-9_+-]+)+)$/),\n\t\tlatitude: coord(i['latitude'], 90),\n\t\tlongitude: coord(i['longitude'], 180),\n\t\taccuracyRadius: radius(i['accuracyRadius']),\n\t\tgeonameId: placeId(i['geonameId']),\n\t\tisp: str(i['isp']),\n\t\torg: str(i['org']),\n\t\tasn: upperCode(i['asn'], /^AS\\d{1,10}$/i),\n\t\tmobile: bool(i['mobile']),\n\t\tproxy: bool(i['proxy']),\n\t\thosting: bool(i['hosting']),\n\t};\n\t// Drop nulls so the stored JSON carries only what is known.\n\tconst known = Object.fromEntries(Object.entries(out).filter(([, v]) => v !== null)) as IRequestLocation;\n\treturn Object.keys(known).length > 0 ? known : null;\n}\n\n/** Run the resolver bounded by a timeout; never throws. */\n// One resolution per request: a sign-in writes a login-event row AND a session\n// row, and both ask. Keyed on the request's Headers object (unique per\n// request, collected with it), so the resolver — possibly a paid API — runs\n// once.\nconst perRequest = new WeakMap<Headers, { resolver: LocationResolver; result: Promise<IRequestLocation | null> }>();\n\nexport function resolveLocation(\n\tresolver: LocationResolver | undefined,\n\treq: ILocationRequest,\n\ttimeoutMs = LOCATION_TIMEOUT_MS,\n): Promise<IRequestLocation | null> {\n\tif (!resolver) return Promise.resolve(null);\n\tconst cached = perRequest.get(req.headers);\n\tif (cached && cached.resolver === resolver) return cached.result;\n\tconst result = resolveOnce(resolver, req, timeoutMs);\n\tperRequest.set(req.headers, { resolver, result });\n\treturn result;\n}\n\nasync function resolveOnce(\n\tresolver: LocationResolver,\n\treq: ILocationRequest,\n\ttimeoutMs: number,\n): Promise<IRequestLocation | null> {\n\tlet timer: ReturnType<typeof setTimeout> | undefined;\n\ttry {\n\t\tconst result = await Promise.race([\n\t\t\tPromise.resolve().then(() => resolver(req)),\n\t\t\tnew Promise<null>((resolve) => {\n\t\t\t\ttimer = setTimeout(() => resolve(null), timeoutMs);\n\t\t\t}),\n\t\t]);\n\t\treturn sanitizeLocation(result);\n\t} catch (err) {\n\t\tconsole.warn('[auth] location resolver failed; recording without a location:', err);\n\t\treturn null;\n\t} finally {\n\t\tif (timer) clearTimeout(timer);\n\t}\n}\n","import type { Middleware } from '@fonderie/core';\nimport type { IStoreAdapter } from '@fonderie/store';\n\nimport type { IAuthConfig } from '../config';\nimport { verifyToken } from '../services/jwt';\nimport type { IAccessPayload } from '../services/jwt';\nimport { UserModel } from '../models/user.model';\nimport { SessionModel } from '../models/session.model';\n\n// Reads the Bearer token or session cookie, populates ctx.user\n// Does NOT reject — anonymous requests pass through\nexport function withSession(store: IStoreAdapter, config: IAuthConfig): Middleware {\n\tconst users = new UserModel(store);\n\tconst sessions = new SessionModel(store);\n\n\treturn async (ctx, next) => {\n\t\tconst token = extractToken(ctx.request);\n\t\tif (!token) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst payload = verifyToken(token, config);\n\t\tif (!payload || payload.type !== 'access') {\n\t\t\treturn next();\n\t\t}\n\n\t\t// Revocation: access tokens are bound to their refresh session via the\n\t\t// sid claim. Logout, rotation, and password change delete that row, so\n\t\t// the access token dies with it instead of living out its JWT expiry.\n\t\t// Tokens without a sid pass through: short-lived mfaPending tokens\n\t\t// (no session exists yet) and legacy tokens from before session\n\t\t// binding, which age out within one accessTokenDuration of deploy.\n\t\tif (payload.sid && !(await sessions.aliveBySid(payload.sid))) {\n\t\t\treturn next();\n\t\t}\n\n\t\tconst user = await users.findById(payload.sub);\n\t\tif (!user || user.suspended || user.deletedAt) {\n\t\t\treturn next();\n\t\t}\n\n\t\tObject.assign(ctx, {\n\t\t\tuser: {\n\t\t\t\t...user,\n\t\t\t\tloginMethod: payload.loginMethod ?? 'email',\n\t\t\t\tphoneVerified: payload.phoneVerified ?? false,\n\t\t\t\tmfaPending: (payload as IAccessPayload).mfaPending ?? false,\n\t\t\t\t// The session this request is authenticated on, so the sessions\n\t\t\t\t// API can flag the current row and \"terminate others\" can spare it.\n\t\t\t\tsid: payload.sid ?? null,\n\t\t\t},\n\t\t});\n\n\t\treturn next();\n\t};\n}\n\nfunction extractToken(request: Request): string | null {\n\tconst auth = request.headers.get('authorization');\n\tif (auth?.startsWith('Bearer ')) {\n\t\treturn auth.slice(7);\n\t}\n\n\tconst cookie = request.headers.get('cookie') ?? '';\n\tconst match = cookie.match(/(?:^|;\\s*)access_token=([^;]+)/);\n\treturn match?.[1] ?? null;\n}\n","export { requireAuth, requireAnyAuth } from '@fonderie/core/middlewares';\n"],"mappings":"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","names":["jwt","import_node_crypto"]}
|