@fonderie/auth 7.9.3 → 7.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,10 +14,17 @@ npm install @fonderie/auth
14
14
 
15
15
  ```ts
16
16
  import { FonderieApp, defineConfig } from '@fonderie/core';
17
+ import { PGAdapter } from '@fonderie/store';
17
18
  import { AuthModule } from '@fonderie/auth';
18
19
 
19
- const app = await new FonderieApp(defineConfig({}))
20
- .register(new AuthModule())
20
+ const store = new PGAdapter(process.env.DATABASE_URL!);
21
+
22
+ const app = await new FonderieApp(defineConfig({ db: { url: process.env.DATABASE_URL! } }))
23
+ .register(new AuthModule(store, {
24
+ providers: ['email'],
25
+ appName: 'my-app',
26
+ jwtSecret: process.env.JWT_SECRET!,
27
+ }))
21
28
  .boot();
22
29
  ```
23
30
 
@@ -30,12 +37,43 @@ import { withSession, requireAuth } from '@fonderie/auth';
30
37
  Also exports `toUserDTO`, `normalizeEmail`, and the full type surface
31
38
  (`IUser`, `ISession`, `IMfaChallenge`, …).
32
39
 
40
+ ## Where did that request come from? (optional)
41
+
42
+ Pass a `location` resolver and auth stamps a location on every event it
43
+ records — each login attempt, each registration, and each new session:
44
+
45
+ ```ts
46
+ import { geoFromHeaders } from '@fonderie/geo';
47
+
48
+ new AuthModule(store, {
49
+ providers: ['email'],
50
+ jwtSecret: process.env.JWT_SECRET!,
51
+ // Vercel/Cloudflare edge headers — zero infrastructure. Trust comes from
52
+ // the deployment, never the request.
53
+ location: ({ headers }) =>
54
+ geoFromHeaders(headers, { trust: process.env.VERCEL ? 'vercel' : undefined }),
55
+ });
56
+ ```
57
+
58
+ Login history then includes `registration` rows alongside sign-ins, and both
59
+ history events and active sessions carry `location: { country, subdivision,
60
+ city, timeZone, … } | null` (plus `isp`/`asn`/`proxy`/`hosting` if your
61
+ resolver knows them). Registration matters most when verification is not
62
+ enforced: the account is live from that request, so it is the first record of
63
+ where the user came from.
64
+
65
+ Run your migrations: `018_login_event_location.sql` and
66
+ `019_session_location.sql` add the columns. The resolver runs at most once per
67
+ request; its output is sanitized and bounded (coordinates ~1 km); if it throws or takes over 500 ms the row is written without a location
68
+ and the request is unaffected. Country is reliable; region and city are
69
+ approximate.
70
+
33
71
  ## Why this exists
34
72
 
35
73
  You've shipped this plumbing before — auth, teams, billing, messaging —
36
74
  and the next project will ask for it again. Fonderie packages it once:
37
75
  plain TypeScript modules for
38
- [`@fonderie/core`](https://github.com/fonderiejs/sdk/tree/main/packages/core),
76
+ [`@fonderie/core`](https://github.com/fonderiejs/fonderie/tree/main/packages/core),
39
77
  PostgreSQL-backed, self-hosted, MIT. No external control plane, no
40
78
  per-seat anything. Register the modules you need; skip the ones you don't.
41
79
 
@@ -43,7 +81,7 @@ per-seat anything. Register the modules you need; skip the ones you don't.
43
81
  other brick trusts the `ctx.user` this one establishes.
44
82
 
45
83
  Browse the whole set at
46
- [fonderiejs/sdk](https://github.com/fonderiejs/sdk) · follow
84
+ [fonderiejs/fonderie](https://github.com/fonderiejs/fonderie) · follow
47
85
  [@fonderiejs](https://x.com/fonderiejs)
48
86
 
49
87
  ## License
package/brain/outcomes.md CHANGED
@@ -38,6 +38,7 @@ failure_reason TEXT
38
38
  ip_address TEXT
39
39
  user_agent TEXT
40
40
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
41
+ location JSONB
41
42
  ```
42
43
 
43
44
  ### `fonderie_mfa_backup_codes`
@@ -93,6 +94,7 @@ ip_address TEXT
93
94
  expires_at TIMESTAMPTZ NOT NULL
94
95
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
95
96
  sid UUID
97
+ location JSONB
96
98
  -- INDEX idx_fonderie_sessions_expires_at (expires_at)
97
99
  -- INDEX idx_fonderie_sessions_sid (sid)
98
100
  ```
@@ -56,6 +56,7 @@ new AuthModule(store: IStoreAdapter, config: IAuthConfig, bus?: EventBus | undef
56
56
  .install(app: IFonderieApp): void
57
57
 
58
58
  interface IAuthConfig extends IAuthSecrets, IAuthRuntimeConfig {
59
+ location?: LocationResolver;
59
60
  secureCookies?: boolean;
60
61
  rateLimit?: IAuthRateLimitConfig | false;
61
62
  accessTokenDuration?: string;
@@ -139,6 +140,7 @@ interface ILoginEventDTO {
139
140
  failureReason: string | null;
140
141
  ipAddress: string | null;
141
142
  userAgent: string | null;
143
+ location: IRequestLocation | null;
142
144
  createdAt: string;
143
145
  }
144
146
 
@@ -152,10 +154,42 @@ interface ISessionDTO {
152
154
  current: boolean;
153
155
  ipAddress: string | null;
154
156
  userAgent: string | null;
157
+ location: IRequestLocation | null;
155
158
  createdAt: string;
156
159
  expiresAt: string;
157
160
  }
158
161
 
162
+ function sanitizeLocation(input: unknown): IRequestLocation | null
163
+
164
+ function resolveLocation(resolver: LocationResolver | undefined, req: ILocationRequest, timeoutMs?: number): Promise<IRequestLocation | null>
165
+
166
+ interface IRequestLocation {
167
+ country?: string | null;
168
+ countryName?: string | null;
169
+ subdivision?: string | null;
170
+ subdivisionName?: string | null;
171
+ city?: string | null;
172
+ postalCode?: string | null;
173
+ continent?: string | null;
174
+ timeZone?: string | null;
175
+ latitude?: number | null;
176
+ longitude?: number | null;
177
+ accuracyRadius?: number | null;
178
+ isp?: string | null;
179
+ org?: string | null;
180
+ asn?: string | null;
181
+ mobile?: boolean | null;
182
+ proxy?: boolean | null;
183
+ hosting?: boolean | null;
184
+ }
185
+
186
+ interface ILocationRequest {
187
+ ip: string | null;
188
+ headers: Headers;
189
+ }
190
+
191
+ type LocationResolver = (req: ILocationRequest) => IRequestLocation | null | undefined | Promise<IRequestLocation | null | undefined>;
192
+
159
193
  function validate(schema: IRequestSchema): Middleware
160
194
 
161
195
  namespace schemas — exports: ChangePasswordInput, LoginInput, RegisterInput, ResetPasswordInput, appleNativeSchema, changePasswordSchema, forgotPasswordSchema, loginSchema, mfaTokenSchema, refreshSchema, registerSchema, resetPasswordSchema, updateEmailSchema, updatePhoneSchema, updatePreferencesSchema, updateProfileSchema, verifySchema
package/dist/index.cjs CHANGED
@@ -80,6 +80,8 @@ __export(index_exports, {
80
80
  normalizeEmailSafe: () => normalizeEmailSafe,
81
81
  purgeSoftDeletedUsers: () => purgeSoftDeletedUsers,
82
82
  requireAuth: () => import_middlewares3.requireAuth,
83
+ resolveLocation: () => resolveLocation,
84
+ sanitizeLocation: () => sanitizeLocation,
83
85
  schemas: () => schemas_exports,
84
86
  startUserRetention: () => startUserRetention,
85
87
  toAdminUserDTO: () => toAdminUserDTO,
@@ -881,18 +883,106 @@ var UserModel = class {
881
883
  };
882
884
  var MAX_LIST_LIMIT = 200;
883
885
 
886
+ // src/services/request-location.ts
887
+ var LOCATION_TIMEOUT_MS = 500;
888
+ var TEXT_MAX = 128;
889
+ function str(v, re) {
890
+ if (typeof v !== "string") return null;
891
+ const t = v.trim();
892
+ if (t.length === 0 || t.length > TEXT_MAX) return null;
893
+ return re && !re.test(t) ? null : t;
894
+ }
895
+ function upperCode(v, re) {
896
+ const t = str(v, re);
897
+ return t ? t.toUpperCase() : null;
898
+ }
899
+ function bool(v) {
900
+ return typeof v === "boolean" ? v : null;
901
+ }
902
+ function coord(v, limit) {
903
+ if (typeof v !== "number" || !Number.isFinite(v) || Math.abs(v) > limit) return null;
904
+ return Math.round(v * 100) / 100;
905
+ }
906
+ function radius(v) {
907
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0 || v > 2e4) return null;
908
+ return Math.round(v);
909
+ }
910
+ function sanitizeLocation(input) {
911
+ if (!input || typeof input !== "object") return null;
912
+ const i = input;
913
+ const out = {
914
+ country: upperCode(i["country"], /^[A-Za-z]{2}$/),
915
+ countryName: str(i["countryName"]),
916
+ subdivision: upperCode(i["subdivision"], /^[A-Za-z0-9]{1,3}$/),
917
+ subdivisionName: str(i["subdivisionName"]),
918
+ city: str(i["city"]),
919
+ postalCode: upperCode(i["postalCode"], /^[A-Za-z0-9][A-Za-z0-9 -]{0,11}$/),
920
+ continent: upperCode(i["continent"], /^[A-Za-z]{2}$/),
921
+ timeZone: str(i["timeZone"], /^(UTC|[A-Za-z_]+(\/[A-Za-z0-9_+-]+)+)$/),
922
+ latitude: coord(i["latitude"], 90),
923
+ longitude: coord(i["longitude"], 180),
924
+ accuracyRadius: radius(i["accuracyRadius"]),
925
+ isp: str(i["isp"]),
926
+ org: str(i["org"]),
927
+ asn: upperCode(i["asn"], /^AS\d{1,10}$/i),
928
+ mobile: bool(i["mobile"]),
929
+ proxy: bool(i["proxy"]),
930
+ hosting: bool(i["hosting"])
931
+ };
932
+ const known = Object.fromEntries(Object.entries(out).filter(([, v]) => v !== null));
933
+ return Object.keys(known).length > 0 ? known : null;
934
+ }
935
+ var perRequest = /* @__PURE__ */ new WeakMap();
936
+ function resolveLocation(resolver, req, timeoutMs = LOCATION_TIMEOUT_MS) {
937
+ if (!resolver) return Promise.resolve(null);
938
+ const cached = perRequest.get(req.headers);
939
+ if (cached && cached.resolver === resolver) return cached.result;
940
+ const result = resolveOnce(resolver, req, timeoutMs);
941
+ perRequest.set(req.headers, { resolver, result });
942
+ return result;
943
+ }
944
+ async function resolveOnce(resolver, req, timeoutMs) {
945
+ let timer;
946
+ try {
947
+ const result = await Promise.race([
948
+ Promise.resolve().then(() => resolver(req)),
949
+ new Promise((resolve2) => {
950
+ timer = setTimeout(() => resolve2(null), timeoutMs);
951
+ })
952
+ ]);
953
+ return sanitizeLocation(result);
954
+ } catch (err) {
955
+ console.warn("[auth] location resolver failed; recording without a location:", err);
956
+ return null;
957
+ } finally {
958
+ if (timer) clearTimeout(timer);
959
+ }
960
+ }
961
+
884
962
  // src/models/session.model.ts
885
963
  var SessionModel = class {
886
- constructor(store) {
964
+ // `locate` is IAuthConfig.location. Absent ⇒ sessions carry no location.
965
+ constructor(store, locate) {
887
966
  this.store = store;
967
+ this.locate = locate;
888
968
  }
889
969
  store;
970
+ locate;
890
971
  async create(userId, token, expiresAt, sid, meta) {
972
+ const location = this.locate && meta?.headers ? await resolveLocation(this.locate, { ip: meta.ipAddress, headers: meta.headers }) : null;
891
973
  await this.store.query(
892
- `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address)
893
- VALUES ($1, $2, $3, $4, $5, $6)
974
+ `INSERT INTO fonderie_sessions (user_id, token, expires_at, sid, user_agent, ip_address, location)
975
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
894
976
  ON CONFLICT (token) DO NOTHING`,
895
- [userId, token, expiresAt, sid ?? null, meta?.userAgent ?? null, meta?.ipAddress ?? null]
977
+ [
978
+ userId,
979
+ token,
980
+ expiresAt,
981
+ sid ?? null,
982
+ meta?.userAgent ?? null,
983
+ meta?.ipAddress ?? null,
984
+ location ? JSON.stringify(location) : null
985
+ ]
896
986
  );
897
987
  }
898
988
  async delete(token) {
@@ -931,6 +1021,7 @@ var SessionModel = class {
931
1021
  `SELECT id, sid,
932
1022
  user_agent AS "userAgent",
933
1023
  ip_address AS "ipAddress",
1024
+ location,
934
1025
  created_at AS "createdAt",
935
1026
  expires_at AS "expiresAt"
936
1027
  FROM fonderie_sessions
@@ -1014,15 +1105,20 @@ var BackupCodeModel = class {
1014
1105
  // src/models/login-event.model.ts
1015
1106
  var import_core4 = require("@fonderie/core");
1016
1107
  var LoginEventModel = class {
1017
- constructor(store) {
1108
+ // `locate` is IAuthConfig.location. Absent ⇒ rows carry no location,
1109
+ // exactly as before.
1110
+ constructor(store, locate) {
1018
1111
  this.store = store;
1112
+ this.locate = locate;
1019
1113
  }
1020
1114
  store;
1115
+ locate;
1021
1116
  async record(e) {
1117
+ const location = this.locate ? await resolveLocation(this.locate, { ip: e.ipAddress, headers: e.headers ?? new Headers() }) : null;
1022
1118
  await this.store.query(
1023
1119
  `INSERT INTO fonderie_login_events
1024
- (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent)
1025
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
1120
+ (user_id, email_attempted, method, outcome, failure_reason, ip_address, user_agent, location)
1121
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
1026
1122
  [
1027
1123
  e.userId,
1028
1124
  e.emailAttempted ?? null,
@@ -1030,7 +1126,8 @@ var LoginEventModel = class {
1030
1126
  e.outcome,
1031
1127
  e.failureReason ?? null,
1032
1128
  e.ipAddress,
1033
- e.userAgent
1129
+ e.userAgent,
1130
+ location ? JSON.stringify(location) : null
1034
1131
  ]
1035
1132
  );
1036
1133
  }
@@ -1069,7 +1166,7 @@ var LoginEventModel = class {
1069
1166
  params.push(limit + 1);
1070
1167
  const rows = await this.store.query(
1071
1168
  `SELECT id, method, outcome, failure_reason AS "failureReason",
1072
- ip_address AS "ipAddress", user_agent AS "userAgent",
1169
+ ip_address AS "ipAddress", user_agent AS "userAgent", location,
1073
1170
  created_at AS "createdAt", created_at::text AS "createdAtRaw"
1074
1171
  FROM fonderie_login_events
1075
1172
  WHERE ${where.join(" AND ")}
@@ -1097,7 +1194,8 @@ function requestMeta(ctx) {
1097
1194
  const ua = ctx.request.headers.get("user-agent");
1098
1195
  const meta = {
1099
1196
  ipAddress: typeof ip === "string" && ip.length > 0 ? ip : null,
1100
- userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null
1197
+ userAgent: ua && ua.length > 0 ? ua.slice(0, UA_MAX) : null,
1198
+ headers: ctx.request.headers
1101
1199
  };
1102
1200
  if (meta.ipAddress === null && meta.userAgent === null) warnIfIdentityless(ctx);
1103
1201
  return meta;
@@ -1106,9 +1204,9 @@ function requestMeta(ctx) {
1106
1204
  // src/controllers/mfa.controller.ts
1107
1205
  function mfaController(store, config, issuer, bus) {
1108
1206
  const users = new UserModel(store);
1109
- const sessions = new SessionModel(store);
1207
+ const sessions = new SessionModel(store, config.location);
1110
1208
  const backupCodes = new BackupCodeModel(store);
1111
- const loginEvents = new LoginEventModel(store);
1209
+ const loginEvents = new LoginEventModel(store, config.location);
1112
1210
  const mfaCipher = makeMfaCipher(config.mfaSecretKey);
1113
1211
  return {
1114
1212
  // ── 1. Setup ───────────────────────────────────────────────
@@ -1506,8 +1604,8 @@ function extractRefreshToken(ctx) {
1506
1604
  }
1507
1605
  function authController(store, config, bus) {
1508
1606
  const users = new UserModel(store);
1509
- const sessions = new SessionModel(store);
1510
- const loginEvents = new LoginEventModel(store);
1607
+ const sessions = new SessionModel(store, config.location);
1608
+ const loginEvents = new LoginEventModel(store, config.location);
1511
1609
  const passwordReset = new PasswordResetModel(store);
1512
1610
  const emailVerif = new EmailVerificationModel(store);
1513
1611
  const phoneVerif = new PhoneVerificationModel(store);
@@ -1575,7 +1673,15 @@ function authController(store, config, bus) {
1575
1673
  const { accessToken, refreshToken, sid } = issueTokenPair(user.id, config, {
1576
1674
  loginMethod: "email"
1577
1675
  });
1578
- await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, requestMeta(ctx));
1676
+ const registerMeta = requestMeta(ctx);
1677
+ await sessions.create(user.id, refreshToken, refreshTokenExpiry(refreshToken), sid, registerMeta);
1678
+ await loginEvents.recordSafe({
1679
+ userId: user.id,
1680
+ emailAttempted: normalizedEmail,
1681
+ method: "registration",
1682
+ outcome: "success",
1683
+ ...registerMeta
1684
+ });
1579
1685
  const resolvedRegister = { ...config, ...config.resolve?.(ctx) };
1580
1686
  const requiresVerification = !!resolvedRegister.requireVerification && !user.emailVerifiedAt;
1581
1687
  return Response.json(
@@ -2134,6 +2240,9 @@ function toLoginEventDTO(row) {
2134
2240
  failureReason: row.failureReason,
2135
2241
  ipAddress: row.ipAddress,
2136
2242
  userAgent: row.userAgent,
2243
+ // Re-sanitized on read: the column is JSONB and could have been written
2244
+ // by anything with database access.
2245
+ location: sanitizeLocation(row.location),
2137
2246
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt)
2138
2247
  };
2139
2248
  }
@@ -2149,6 +2258,7 @@ function toSessionDTO(row, currentSid) {
2149
2258
  current: currentSid !== null && row.sid === currentSid,
2150
2259
  ipAddress: row.ipAddress,
2151
2260
  userAgent: row.userAgent,
2261
+ location: sanitizeLocation(row.location),
2152
2262
  createdAt: row.createdAt instanceof Date ? row.createdAt.toISOString() : String(row.createdAt),
2153
2263
  expiresAt: row.expiresAt instanceof Date ? row.expiresAt.toISOString() : String(row.expiresAt)
2154
2264
  };
@@ -2163,8 +2273,8 @@ function isValidPhone2(phone2) {
2163
2273
  }
2164
2274
  function userController(store, config, bus) {
2165
2275
  const users = new UserModel(store);
2166
- const sessions = new SessionModel(store);
2167
- const loginEvents = new LoginEventModel(store);
2276
+ const sessions = new SessionModel(store, config.location);
2277
+ const loginEvents = new LoginEventModel(store, config.location);
2168
2278
  const emailVerif = new EmailVerificationModel(store);
2169
2279
  const phoneVerif = new PhoneVerificationModel(store);
2170
2280
  return {
@@ -2608,8 +2718,8 @@ async function verifyAppleIdToken(idToken, opts) {
2608
2718
  var appleEmailVerified = (c) => c.email_verified === true || c.email_verified === "true";
2609
2719
  function oauthController(store, config, bus) {
2610
2720
  const users = new UserModel(store);
2611
- const sessions = new SessionModel(store);
2612
- const loginEvents = new LoginEventModel(store);
2721
+ const sessions = new SessionModel(store, config.location);
2722
+ const loginEvents = new LoginEventModel(store, config.location);
2613
2723
  const consumedTokens = new ConsumedTokenModel(store);
2614
2724
  const announceOAuthUpsert = async (ctx, upserted, provider, user) => {
2615
2725
  if (!bus) return;
@@ -3398,7 +3508,7 @@ var AuthModule = class {
3398
3508
  name = "@fonderie/auth";
3399
3509
  // Baked in at build time by tsup.base, so the operator's Modules page can
3400
3510
  // say what is actually deployed rather than 'not reported'.
3401
- version = "7.9.3";
3511
+ version = "7.10.0";
3402
3512
  // Users, sessions, login history, suspend — only through @fonderie/admin.
3403
3513
  describeAdmin() {
3404
3514
  return { routes: describeAuthAdminRoutes(this.store) };
@@ -3676,6 +3786,8 @@ function startUserRetention(store, options) {
3676
3786
  normalizeEmailSafe,
3677
3787
  purgeSoftDeletedUsers,
3678
3788
  requireAuth,
3789
+ resolveLocation,
3790
+ sanitizeLocation,
3679
3791
  schemas,
3680
3792
  startUserRetention,
3681
3793
  toAdminUserDTO,