@fonderie/auth 7.10.0 → 7.11.1
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/brain/signatures.md +1 -0
- package/dist/index.cjs +6 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/middlewares/index.cjs +5 -0
- package/dist/middlewares/index.cjs.map +1 -1
- package/dist/middlewares/index.d.cts +1 -1
- package/dist/middlewares/index.d.ts +1 -1
- package/dist/middlewares/index.js +5 -0
- package/dist/middlewares/index.js.map +1 -1
- package/dist/{session-DU9QTf7f.d.cts → session-BBF5t-3Y.d.cts} +4 -0
- package/dist/{session-DU9QTf7f.d.ts → session-BBF5t-3Y.d.ts} +4 -0
- package/package.json +3 -3
|
@@ -395,6 +395,10 @@ function radius(v) {
|
|
|
395
395
|
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0 || v > 2e4) return null;
|
|
396
396
|
return Math.round(v);
|
|
397
397
|
}
|
|
398
|
+
function placeId(v) {
|
|
399
|
+
const n = typeof v === "string" && /^\d{1,10}$/.test(v) ? Number(v) : v;
|
|
400
|
+
return typeof n === "number" && Number.isInteger(n) && n > 0 && n <= 2147483647 ? n : null;
|
|
401
|
+
}
|
|
398
402
|
function sanitizeLocation(input) {
|
|
399
403
|
if (!input || typeof input !== "object") return null;
|
|
400
404
|
const i = input;
|
|
@@ -410,6 +414,7 @@ function sanitizeLocation(input) {
|
|
|
410
414
|
latitude: coord(i["latitude"], 90),
|
|
411
415
|
longitude: coord(i["longitude"], 180),
|
|
412
416
|
accuracyRadius: radius(i["accuracyRadius"]),
|
|
417
|
+
geonameId: placeId(i["geonameId"]),
|
|
413
418
|
isp: str(i["isp"]),
|
|
414
419
|
org: str(i["org"]),
|
|
415
420
|
asn: upperCode(i["asn"], /^AS\d{1,10}$/i),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/services/jwt.ts","../../src/models/user.model.ts","../../src/services/request-location.ts","../../src/models/session.model.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 { randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport 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}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\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' },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\tconst duration = config.sessionDuration ?? '7d';\n\tconst accessDuration = config.accessTokenDuration ?? '24h';\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: accessDuration } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration } 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\ttry {\n\t\treturn jwt.verify(token, config.jwtSecret) as IAccessPayload | IRefreshPayload;\n\t} catch {\n\t\treturn null;\n\t}\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[] = ['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\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): 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)\n\t\t\tVALUES ($1, $2, $3)\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],\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): 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)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName],\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}\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","// 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/** 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/** 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\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 { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\nimport { type IRequestLocation, type LocationResolver, resolveLocation } from '../services/request-location';\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)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[\n\t\t\t\tuserId,\n\t\t\t\ttoken,\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],\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 = $1`, [token]);\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\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,\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\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length > 0;\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\tconst rows = await this.store.query<{ id: string }>(\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 id`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.length;\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 = $1 AND expires_at > now()`,\n\t\t\t[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","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"]}
|
|
1
|
+
{"version":3,"sources":["../../src/middlewares/index.ts","../../src/services/jwt.ts","../../src/models/user.model.ts","../../src/services/request-location.ts","../../src/models/session.model.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 { randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport 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}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\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' },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\tconst duration = config.sessionDuration ?? '7d';\n\tconst accessDuration = config.accessTokenDuration ?? '24h';\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: accessDuration } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration } 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\ttry {\n\t\treturn jwt.verify(token, config.jwtSecret) as IAccessPayload | IRefreshPayload;\n\t} catch {\n\t\treturn null;\n\t}\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[] = ['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\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): 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)\n\t\t\tVALUES ($1, $2, $3)\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],\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): 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)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName],\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}\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","// 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 { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\nimport { type IRequestLocation, type LocationResolver, resolveLocation } from '../services/request-location';\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)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[\n\t\t\t\tuserId,\n\t\t\t\ttoken,\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],\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 = $1`, [token]);\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\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,\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\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length > 0;\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\tconst rows = await this.store.query<{ id: string }>(\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 id`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.length;\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 = $1 AND expires_at > now()`,\n\t\t\t[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","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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;;;ACAA,yBAA2B;AAC3B,0BAAsC;AAwF/B,SAAS,YACf,OACA,QAC0C;AAC1C,MAAI;AACH,WAAO,oBAAAA,QAAI,OAAO,OAAO,OAAO,SAAS;AAAA,EAC1C,QAAQ;AACP,WAAO;AAAA,EACR;AACD;;;ACrFA,IAAM,eAAe;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAwBd,IAAM,YAAN,MAAgB;AAAA,EACtB,YAAoB,OAAsB;AAAtB;AAAA,EAAuB;AAAA,EAAvB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMpB,MAAM,KAAK,QAAwB,CAAC,GAAuB;AAC1D,UAAM,QAAQ,KAAK,IAAI,MAAM,SAAS,IAAI,cAAc;AACxD,UAAM,SAAoB,CAAC;AAC3B,UAAM,QAAkB,CAAC,oBAAoB;AAE7C,QAAI,MAAM,QAAQ;AACjB,aAAO,KAAK,MAAM,OAAO,WAAW,MAAM,OAAO,EAAE;AACnD,YAAM,KAAK,wBAAwB,OAAO,SAAS,CAAC,mBAAmB,OAAO,MAAM,SAAS;AAAA,IAC9F;AAEA,WAAO,KAAK,QAAQ,CAAC;AACrB,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B,UAAU,YAAY;AAAA;AAAA,aAEZ,MAAM,KAAK,OAAO,CAAC;AAAA;AAAA,cAElB,OAAO,MAAM;AAAA,MACxB;AAAA,IACD;AACA,WAAO,EAAE,OAAO,KAAK,MAAM,GAAG,KAAK,GAAG,SAAS,KAAK,SAAS,MAAM;AAAA,EACpE;AAAA,EAEA,MAAM,SAAS,IAAmC;AACjD,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,UAAU,YAAY;AAAA,MACtB,CAAC,EAAE;AAAA,IACJ;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,YAAY,OAAsC;AACvD,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,UAAU,YAAY;AAAA,MACtB,CAAC,KAAK;AAAA,IACP;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,YAAY,OAAsC;AACvD,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,UAAU,YAAY;AAAA,MACtB,CAAC,KAAK;AAAA,IACP;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,oBACL,OACA,YAA2B,MAC3B,WAA0B,MACA;AAC1B,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAOA,CAAC,OAAO,WAAW,QAAQ;AAAA,IAC5B;AACA,WAAO;AAAA,EACR;AAAA,EAEA,MAAM,OACL,OACA,cACA,WACA,UACiC;AACjC,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B;AAAA;AAAA;AAAA,MAGA,CAAC,MAAM,YAAY,EAAE,KAAK,GAAG,cAAc,WAAW,QAAQ;AAAA,IAC/D;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,OAAO,IAAY,QAA2D;AACnF,UAAM,YAAoC;AAAA,MACzC,WAAW;AAAA,MACX,UAAU;AAAA,MACV,aAAa;AAAA,MACb,WAAW;AAAA,MACX,QAAQ;AAAA,MACR,UAAU;AAAA,MACV,aAAa;AAAA,IACd;AAEA,UAAM,OAAiB,CAAC;AACxB,UAAM,SAAoB,CAAC;AAE3B,eAAW,CAAC,KAAK,GAAG,KAAK,OAAO,QAAQ,SAAS,GAAG;AACnD,UAAK,OAAmC,GAAG,MAAM,QAAW;AAC3D,eAAO,KAAM,OAAmC,GAAG,CAAC;AACpD,aAAK,KAAK,GAAG,GAAG,OAAO,OAAO,MAAM,EAAE;AAAA,MACvC;AAAA,IACD;AAEA,WAAO,KAAK,EAAE;AACd,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,6BAA6B,KAAK,KAAK,IAAI,CAAC,oCAAoC,OAAO,MAAM;AAAA,MAC7F;AAAA,IACD;AACA,WAAO,OAAO;AAAA,EACf;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAeA,MAAM,cAAc,IAA8B;AACjD,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAMA,CAAC,EAAE;AAAA,IACJ;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AAAA,EAEA,MAAM,eAAe,IAAY,cAAqC;AACrE,UAAM,KAAK,MAAM,MAAM,8DAA8D;AAAA,MACpF;AAAA,MACA;AAAA,IACD,CAAC;AAAA,EACF;AAAA,EAEA,MAAM,kBAAkB,IAA2B;AAClD,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,EAAE;AAAA,IACJ;AAAA,EACD;AAAA;AAAA;AAAA,EAIA,MAAM,aAAa,IAAY,WAAsC;AACpE,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,IAAI,SAAS;AAAA,IACf;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AAAA,EAEA,MAAM,WAAW,IAA2B;AAC3C,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,EAAE;AAAA,IACJ;AAAA,EACD;AAAA,EAEA,MAAM,cAAc,IAAY,QAA+B;AAC9D,UAAM,KAAK,MAAM,MAAM,2DAA2D,CAAC,QAAQ,EAAE,CAAC;AAAA,EAC/F;AAAA,EAEA,MAAM,qBAAqB,IAAY,QAA+B;AACrE,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA;AAAA;AAAA,MAGA,CAAC,QAAQ,EAAE;AAAA,IACZ;AAAA,EACD;AAAA,EAEA,MAAM,oBAAoB,IAAoC;AAC7D,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B;AAAA;AAAA,MAEA,CAAC,EAAE;AAAA,IACJ;AACA,WAAO,KAAK,sBAAsB;AAAA,EACnC;AAAA,EAEA,MAAM,iBAAiB,IAA2B;AACjD,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAOA,CAAC,EAAE;AAAA,IACJ;AAAA,EACD;AAAA,EAEA,MAAM,UAAU,IAA2B;AAC1C,UAAM,KAAK,MAAM,MAAM,8DAA8D,CAAC,EAAE,CAAC;AAAA,EAC1F;AAAA,EAEA,MAAM,WAAW,IAA2B;AAC3C,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,EAAE;AAAA,IACJ;AAAA,EACD;AAAA,EAEA,MAAM,YAAY,IAAY,OAA8B;AAC3D,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,MAAM,YAAY,EAAE,KAAK,GAAG,EAAE;AAAA,IAChC;AAAA,EACD;AAAA,EAEA,MAAM,YAAY,IAAY,OAA8B;AAC3D,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA,MACA,CAAC,OAAO,EAAE;AAAA,IACX;AAAA,EACD;AAAA,EAEA,MAAM,kBACL,IACA,QAKiC;AACjC,UAAM,OAAiB,CAAC;AACxB,UAAM,SAAoB,CAAC;AAE3B,QAAI,OAAO,WAAW,QAAW;AAChC,aAAO,KAAK,OAAO,MAAM;AACzB,WAAK,KAAK,aAAa,OAAO,MAAM,EAAE;AAAA,IACvC;AACA,QAAI,OAAO,aAAa,QAAW;AAClC,aAAO,KAAK,OAAO,QAAQ;AAC3B,WAAK,KAAK,eAAe,OAAO,MAAM,EAAE;AAAA,IACzC;AACA,QAAI,OAAO,SAAS,OAAO,KAAK,OAAO,KAAK,EAAE,SAAS,GAAG;AACzD,aAAO,KAAK,KAAK,UAAU,OAAO,KAAK,CAAC;AACxC,WAAK,KAAK,iCAAiC,OAAO,MAAM,SAAS;AAAA,IAClE;AAEA,QAAI,KAAK,WAAW,EAAG,QAAO;AAE9B,WAAO,KAAK,EAAE;AACd,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B,6BAA6B,KAAK,KAAK,IAAI,CAAC,oCAAoC,OAAO,MAAM;AAAA,MAC7F;AAAA,IACD;AACA,WAAO,OAAO;AAAA,EACf;AAAA,EAEA,MAAM,aAAa,IAAoC;AACtD,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAC9B;AAAA,MACA,CAAC,EAAE;AAAA,IACJ;AACA,WAAO,KAAK,cAAc;AAAA,EAC3B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAmCA,MAAM,iBACL,OACA,UACA,YAMS;AACT,UAAM,CAAC,GAAG,IAAI,MAAM,KAAK,MAAM;AAAA,MAM9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MA8BA,CAAC,OAAO,UAAU,UAAU;AAAA,IAC7B;AACA,WAAO,OAAO;AAAA,EACf;AACD;AAEA,IAAM,iBAAiB;;;AC7UvB,IAAM,sBAAsB;AAE5B,IAAM,WAAW;AAEjB,SAAS,IAAI,GAAY,IAA4B;AACpD,MAAI,OAAO,MAAM,SAAU,QAAO;AAClC,QAAM,IAAI,EAAE,KAAK;AACjB,MAAI,EAAE,WAAW,KAAK,EAAE,SAAS,SAAU,QAAO;AAClD,SAAO,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,OAAO;AACnC;AACA,SAAS,UAAU,GAAY,IAA2B;AACzD,QAAM,IAAI,IAAI,GAAG,EAAE;AACnB,SAAO,IAAI,EAAE,YAAY,IAAI;AAC9B;AACA,SAAS,KAAK,GAA4B;AACzC,SAAO,OAAO,MAAM,YAAY,IAAI;AACrC;AAGA,SAAS,MAAM,GAAY,OAA8B;AACxD,MAAI,OAAO,MAAM,YAAY,CAAC,OAAO,SAAS,CAAC,KAAK,KAAK,IAAI,CAAC,IAAI,MAAO,QAAO;AAChF,SAAO,KAAK,MAAM,IAAI,GAAG,IAAI;AAC9B;AAIA,SAAS,OAAO,GAA2B;AAC1C,MAAI,OAAO,MAAM,YAAY,CAAC,OAAO,SAAS,CAAC,KAAK,KAAK,KAAK,IAAI,IAAQ,QAAO;AACjF,SAAO,KAAK,MAAM,CAAC;AACpB;AAGA,SAAS,QAAQ,GAA2B;AAC3C,QAAM,IAAI,OAAO,MAAM,YAAY,aAAa,KAAK,CAAC,IAAI,OAAO,CAAC,IAAI;AACtE,SAAO,OAAO,MAAM,YAAY,OAAO,UAAU,CAAC,KAAK,IAAI,KAAK,KAAK,aAAgB,IAAI;AAC1F;AAGO,SAAS,iBAAiB,OAAyC;AACzE,MAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,QAAM,IAAI;AACV,QAAM,MAAwB;AAAA,IAC7B,SAAS,UAAU,EAAE,SAAS,GAAG,eAAe;AAAA,IAChD,aAAa,IAAI,EAAE,aAAa,CAAC;AAAA,IACjC,aAAa,UAAU,EAAE,aAAa,GAAG,oBAAoB;AAAA,IAC7D,iBAAiB,IAAI,EAAE,iBAAiB,CAAC;AAAA,IACzC,MAAM,IAAI,EAAE,MAAM,CAAC;AAAA,IACnB,YAAY,UAAU,EAAE,YAAY,GAAG,kCAAkC;AAAA,IACzE,WAAW,UAAU,EAAE,WAAW,GAAG,eAAe;AAAA,IACpD,UAAU,IAAI,EAAE,UAAU,GAAG,wCAAwC;AAAA,IACrE,UAAU,MAAM,EAAE,UAAU,GAAG,EAAE;AAAA,IACjC,WAAW,MAAM,EAAE,WAAW,GAAG,GAAG;AAAA,IACpC,gBAAgB,OAAO,EAAE,gBAAgB,CAAC;AAAA,IAC1C,WAAW,QAAQ,EAAE,WAAW,CAAC;AAAA,IACjC,KAAK,IAAI,EAAE,KAAK,CAAC;AAAA,IACjB,KAAK,IAAI,EAAE,KAAK,CAAC;AAAA,IACjB,KAAK,UAAU,EAAE,KAAK,GAAG,eAAe;AAAA,IACxC,QAAQ,KAAK,EAAE,QAAQ,CAAC;AAAA,IACxB,OAAO,KAAK,EAAE,OAAO,CAAC;AAAA,IACtB,SAAS,KAAK,EAAE,SAAS,CAAC;AAAA,EAC3B;AAEA,QAAM,QAAQ,OAAO,YAAY,OAAO,QAAQ,GAAG,EAAE,OAAO,CAAC,CAAC,EAAE,CAAC,MAAM,MAAM,IAAI,CAAC;AAClF,SAAO,OAAO,KAAK,KAAK,EAAE,SAAS,IAAI,QAAQ;AAChD;AAOA,IAAM,aAAa,oBAAI,QAA2F;AAE3G,SAAS,gBACf,UACA,KACA,YAAY,qBACuB;AACnC,MAAI,CAAC,SAAU,QAAO,QAAQ,QAAQ,IAAI;AAC1C,QAAM,SAAS,WAAW,IAAI,IAAI,OAAO;AACzC,MAAI,UAAU,OAAO,aAAa,SAAU,QAAO,OAAO;AAC1D,QAAM,SAAS,YAAY,UAAU,KAAK,SAAS;AACnD,aAAW,IAAI,IAAI,SAAS,EAAE,UAAU,OAAO,CAAC;AAChD,SAAO;AACR;AAEA,eAAe,YACd,UACA,KACA,WACmC;AACnC,MAAI;AACJ,MAAI;AACH,UAAM,SAAS,MAAM,QAAQ,KAAK;AAAA,MACjC,QAAQ,QAAQ,EAAE,KAAK,MAAM,SAAS,GAAG,CAAC;AAAA,MAC1C,IAAI,QAAc,CAAC,YAAY;AAC9B,gBAAQ,WAAW,MAAM,QAAQ,IAAI,GAAG,SAAS;AAAA,MAClD,CAAC;AAAA,IACF,CAAC;AACD,WAAO,iBAAiB,MAAM;AAAA,EAC/B,SAAS,KAAK;AACb,YAAQ,KAAK,kEAAkE,GAAG;AAClF,WAAO;AAAA,EACR,UAAE;AACD,QAAI,MAAO,cAAa,KAAK;AAAA,EAC9B;AACD;;;ACpKO,IAAM,eAAN,MAAmB;AAAA;AAAA,EAEzB,YACS,OACA,QACP;AAFO;AACA;AAAA,EACN;AAAA,EAFM;AAAA,EACA;AAAA,EAGT,MAAM,OACL,QACA,OACA,WACA,KACA,MACgB;AAChB,UAAM,WACL,KAAK,UAAU,MAAM,UAClB,MAAM,gBAAgB,KAAK,QAAQ,EAAE,IAAI,KAAK,WAAW,SAAS,KAAK,QAAQ,CAAC,IAChF;AACJ,UAAM,KAAK,MAAM;AAAA,MAChB;AAAA;AAAA;AAAA,MAGA;AAAA,QACC;AAAA,QACA;AAAA,QACA;AAAA,QACA,OAAO;AAAA,QACP,MAAM,aAAa;AAAA,QACnB,MAAM,aAAa;AAAA,QACnB,WAAW,KAAK,UAAU,QAAQ,IAAI;AAAA,MACvC;AAAA,IACD;AAAA,EACD;AAAA,EAEA,MAAM,OAAO,OAA8B;AAC1C,UAAM,KAAK,MAAM,MAAM,kDAAkD,CAAC,KAAK,CAAC;AAAA,EACjF;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,YAAY,KAA4B;AAC7C,UAAM,KAAK,MAAM,MAAM,gDAAgD,CAAC,GAAG,CAAC;AAAA,EAC7E;AAAA;AAAA;AAAA,EAIA,MAAM,aAAa,QAA+B;AACjD,UAAM,KAAK,MAAM,MAAM,oDAAoD,CAAC,MAAM,CAAC;AAAA,EACpF;AAAA;AAAA,EAGA,MAAM,WAAW,QAEf;AACD,WAAO,KAAK,MAAM;AAAA,MACjB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MAQA,CAAC,MAAM;AAAA,IACR;AAAA,EACD;AAAA;AAAA;AAAA;AAAA,EAKA,MAAM,eAAe,QAUnB;AACD,WAAO,KAAK,MAAM;AAAA,MACjB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,MASA,CAAC,MAAM;AAAA,IACR;AAAA,EACD;AAAA;AAAA;AAAA,EAIA,MAAM,cAAc,QAAgB,IAA8B;AACjE,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,IAAI,MAAM;AAAA,IACZ;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AAAA;AAAA;AAAA,EAIA,MAAM,gBAAgB,QAAgB,SAAkC;AACvE,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA;AAAA;AAAA,MAGA,CAAC,QAAQ,OAAO;AAAA,IACjB;AACA,WAAO,KAAK;AAAA,EACb;AAAA,EAEA,MAAM,OAAO,OAAiC;AAC7C,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,KAAK;AAAA,IACP;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AAAA;AAAA,EAGA,MAAM,WAAW,KAA+B;AAC/C,UAAM,OAAO,MAAM,KAAK,MAAM;AAAA,MAC7B;AAAA,MACA,CAAC,GAAG;AAAA,IACL;AACA,WAAO,KAAK,SAAS;AAAA,EACtB;AACD;;;AChIO,SAAS,YAAY,OAAsB,QAAiC;AAClF,QAAM,QAAQ,IAAI,UAAU,KAAK;AACjC,QAAM,WAAW,IAAI,aAAa,KAAK;AAEvC,SAAO,OAAO,KAAK,SAAS;AAC3B,UAAM,QAAQ,aAAa,IAAI,OAAO;AACtC,QAAI,CAAC,OAAO;AACX,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,UAAU,YAAY,OAAO,MAAM;AACzC,QAAI,CAAC,WAAW,QAAQ,SAAS,UAAU;AAC1C,aAAO,KAAK;AAAA,IACb;AAQA,QAAI,QAAQ,OAAO,CAAE,MAAM,SAAS,WAAW,QAAQ,GAAG,GAAI;AAC7D,aAAO,KAAK;AAAA,IACb;AAEA,UAAM,OAAO,MAAM,MAAM,SAAS,QAAQ,GAAG;AAC7C,QAAI,CAAC,QAAQ,KAAK,aAAa,KAAK,WAAW;AAC9C,aAAO,KAAK;AAAA,IACb;AAEA,WAAO,OAAO,KAAK;AAAA,MAClB,MAAM;AAAA,QACL,GAAG;AAAA,QACH,aAAa,QAAQ,eAAe;AAAA,QACpC,eAAe,QAAQ,iBAAiB;AAAA,QACxC,YAAa,QAA2B,cAAc;AAAA;AAAA;AAAA,QAGtD,KAAK,QAAQ,OAAO;AAAA,MACrB;AAAA,IACD,CAAC;AAED,WAAO,KAAK;AAAA,EACb;AACD;AAEA,SAAS,aAAa,SAAiC;AACtD,QAAM,OAAO,QAAQ,QAAQ,IAAI,eAAe;AAChD,MAAI,MAAM,WAAW,SAAS,GAAG;AAChC,WAAO,KAAK,MAAM,CAAC;AAAA,EACpB;AAEA,QAAM,SAAS,QAAQ,QAAQ,IAAI,QAAQ,KAAK;AAChD,QAAM,QAAQ,OAAO,MAAM,gCAAgC;AAC3D,SAAO,QAAQ,CAAC,KAAK;AACtB;;;AClEA,yBAA4C;;;ANG5C,IAAM,cAAc;AAAA,EACnB;AAAA,EACA;AACD;AAEA,IAAO,sBAAQ;","names":["jwt"]}
|
|
@@ -359,6 +359,10 @@ function radius(v) {
|
|
|
359
359
|
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0 || v > 2e4) return null;
|
|
360
360
|
return Math.round(v);
|
|
361
361
|
}
|
|
362
|
+
function placeId(v) {
|
|
363
|
+
const n = typeof v === "string" && /^\d{1,10}$/.test(v) ? Number(v) : v;
|
|
364
|
+
return typeof n === "number" && Number.isInteger(n) && n > 0 && n <= 2147483647 ? n : null;
|
|
365
|
+
}
|
|
362
366
|
function sanitizeLocation(input) {
|
|
363
367
|
if (!input || typeof input !== "object") return null;
|
|
364
368
|
const i = input;
|
|
@@ -374,6 +378,7 @@ function sanitizeLocation(input) {
|
|
|
374
378
|
latitude: coord(i["latitude"], 90),
|
|
375
379
|
longitude: coord(i["longitude"], 180),
|
|
376
380
|
accuracyRadius: radius(i["accuracyRadius"]),
|
|
381
|
+
geonameId: placeId(i["geonameId"]),
|
|
377
382
|
isp: str(i["isp"]),
|
|
378
383
|
org: str(i["org"]),
|
|
379
384
|
asn: upperCode(i["asn"], /^AS\d{1,10}$/i),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/services/jwt.ts","../../src/models/user.model.ts","../../src/services/request-location.ts","../../src/models/session.model.ts","../../src/middlewares/session.ts","../../src/middlewares/require-auth.ts","../../src/middlewares/index.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport 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}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\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' },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\tconst duration = config.sessionDuration ?? '7d';\n\tconst accessDuration = config.accessTokenDuration ?? '24h';\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: accessDuration } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration } 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\ttry {\n\t\treturn jwt.verify(token, config.jwtSecret) as IAccessPayload | IRefreshPayload;\n\t} catch {\n\t\treturn null;\n\t}\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[] = ['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\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): 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)\n\t\t\tVALUES ($1, $2, $3)\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],\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): 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)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName],\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}\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","// 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/** 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/** 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\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 { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\nimport { type IRequestLocation, type LocationResolver, resolveLocation } from '../services/request-location';\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)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[\n\t\t\t\tuserId,\n\t\t\t\ttoken,\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],\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 = $1`, [token]);\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\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,\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\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length > 0;\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\tconst rows = await this.store.query<{ id: string }>(\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 id`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.length;\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 = $1 AND expires_at > now()`,\n\t\t\t[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","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","import { withSession } from './session';\nimport { requireAuth } from './require-auth';\n\nconst middlewares = {\n\trequireAuth,\n\twithSession,\n};\n\nexport default middlewares;\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/services/jwt.ts","../../src/models/user.model.ts","../../src/services/request-location.ts","../../src/models/session.model.ts","../../src/middlewares/session.ts","../../src/middlewares/require-auth.ts","../../src/middlewares/index.ts"],"sourcesContent":["import { randomUUID } from 'node:crypto';\nimport jwt, { type SignOptions } from 'jsonwebtoken';\n\nimport 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}\n\nexport interface IRefreshPayload {\n\tsub: string;\n\ttype: 'refresh';\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified: boolean;\n\tsid?: string;\n}\n\nexport interface ITokenOptions {\n\tloginMethod: 'email' | 'phone' | 'google' | 'apple';\n\tphoneVerified?: boolean;\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' },\n\t);\n}\n\nexport function issueTokenPair(\n\tuserId: string,\n\tconfig: IAuthConfig,\n\toptions: ITokenOptions,\n): TokenPair {\n\tconst duration = config.sessionDuration ?? '7d';\n\tconst accessDuration = config.accessTokenDuration ?? '24h';\n\tconst loginMethod = options.loginMethod;\n\tconst phoneVerified = options.phoneVerified ?? false;\n\tconst sid = randomUUID();\n\n\tconst accessToken = jwt.sign(\n\t\t{ sub: userId, type: 'access', loginMethod, phoneVerified, sid } satisfies IAccessPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: accessDuration } as SignOptions,\n\t);\n\n\tconst refreshToken = jwt.sign(\n\t\t{ sub: userId, type: 'refresh', loginMethod, phoneVerified, sid } satisfies IRefreshPayload,\n\t\tconfig.jwtSecret,\n\t\t{ expiresIn: duration } 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\ttry {\n\t\treturn jwt.verify(token, config.jwtSecret) as IAccessPayload | IRefreshPayload;\n\t} catch {\n\t\treturn null;\n\t}\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[] = ['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\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): 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)\n\t\t\tVALUES ($1, $2, $3)\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],\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): 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)\n\t\t\tVALUES ($1, $2, $3, $4)\n\t\t\tRETURNING id`,\n\t\t\t[email.toLowerCase().trim(), passwordHash, firstName, lastName],\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}\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","// 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 { IStoreAdapter } from '@fonderie/store';\n\nimport type { IRequestMeta } from '../services/request-meta';\nimport { type IRequestLocation, type LocationResolver, resolveLocation } from '../services/request-location';\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)\n\t\t\tVALUES ($1, $2, $3, $4, $5, $6, $7)\n\t\t\tON CONFLICT (token) DO NOTHING`,\n\t\t\t[\n\t\t\t\tuserId,\n\t\t\t\ttoken,\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],\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 = $1`, [token]);\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\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,\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\tconst rows = await this.store.query<{ id: string }>(\n\t\t\t`DELETE FROM fonderie_sessions WHERE id = $1 AND user_id = $2 RETURNING id`,\n\t\t\t[id, userId],\n\t\t);\n\t\treturn rows.length > 0;\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\tconst rows = await this.store.query<{ id: string }>(\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 id`,\n\t\t\t[userId, keepSid],\n\t\t);\n\t\treturn rows.length;\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 = $1 AND expires_at > now()`,\n\t\t\t[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","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","import { withSession } from './session';\nimport { requireAuth } from './require-auth';\n\nconst middlewares = {\n\trequireAuth,\n\twithSession,\n};\n\nexport default middlewares;\n"],"mappings":"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","names":[]}
|
|
@@ -18,6 +18,10 @@ interface IRequestLocation {
|
|
|
18
18
|
/** How far off the point may be, in kilometres (MaxMind's accuracy_radius).
|
|
19
19
|
* Lets a UI say "near Mountain View (±20 km)" instead of implying precision. */
|
|
20
20
|
accuracyRadius?: number | null;
|
|
21
|
+
/** A stable, language-neutral place key (MaxMind/GeoNames id). Lets a reader
|
|
22
|
+
* see the stored place in their own language later, and lets an app map it
|
|
23
|
+
* to its own regions. Names above are a snapshot; this is the reference. */
|
|
24
|
+
geonameId?: number | null;
|
|
21
25
|
/** Network facts — only resolvers backed by an IP-intelligence API know these. */
|
|
22
26
|
isp?: string | null;
|
|
23
27
|
org?: string | null;
|
|
@@ -18,6 +18,10 @@ interface IRequestLocation {
|
|
|
18
18
|
/** How far off the point may be, in kilometres (MaxMind's accuracy_radius).
|
|
19
19
|
* Lets a UI say "near Mountain View (±20 km)" instead of implying precision. */
|
|
20
20
|
accuracyRadius?: number | null;
|
|
21
|
+
/** A stable, language-neutral place key (MaxMind/GeoNames id). Lets a reader
|
|
22
|
+
* see the stored place in their own language later, and lets an app map it
|
|
23
|
+
* to its own regions. Names above are a snapshot; this is the reference. */
|
|
24
|
+
geonameId?: number | null;
|
|
21
25
|
/** Network facts — only resolvers backed by an IP-intelligence API know these. */
|
|
22
26
|
isp?: string | null;
|
|
23
27
|
org?: string | null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fonderie/auth",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.11.1",
|
|
4
4
|
"fonderie": { "stability": "stable" },
|
|
5
5
|
"description": "Drop-in auth for SaaS — email/password, phone OTP, Google OAuth, stateless JWT sessions, TOTP-based MFA with backup codes, and self-service password recovery.",
|
|
6
6
|
"keywords": [
|
|
@@ -59,12 +59,12 @@
|
|
|
59
59
|
"jsonwebtoken": "^9.0.3",
|
|
60
60
|
"qrcode": "^1.5.4",
|
|
61
61
|
"zod": "^4.6.5",
|
|
62
|
-
"@fonderie/rate-limit": "^4.0.
|
|
62
|
+
"@fonderie/rate-limit": "^4.0.23"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
65
|
"@fonderie/core": "^0.21.0",
|
|
66
66
|
"@fonderie/events": "^5.0.0",
|
|
67
|
-
"@fonderie/store": "^0.
|
|
67
|
+
"@fonderie/store": "^0.7.0"
|
|
68
68
|
},
|
|
69
69
|
"peerDependenciesMeta": {
|
|
70
70
|
"@fonderie/events": {
|