Add GeoIP lookup support and update device fingerprints to base58
- Add maxmind library for GeoIP database lookups when not behind Cloudflare - Extract client IP from multiple header sources (CF, X-Real-IP, X-Forwarded-For, etc.) - Change device fingerprints from UUID to base58 with device_ prefix - Add isValidDeviceFingerprint() that accepts both new and legacy formats - Colocate unit tests with source files, remove __tests__/unit directory - Add test coverage reporting to test script Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
"lint": "eslint . --cache",
|
"lint": "eslint . --cache",
|
||||||
"clean": "rm -rf dist .eslintcache",
|
"clean": "rm -rf dist .eslintcache",
|
||||||
"test:e2e": "bun test src/__tests__/e2e --no-parallel",
|
"test:e2e": "bun test src/__tests__/e2e --no-parallel",
|
||||||
"test:unit": "bun test src/__tests__/unit"
|
"test": "bun test --coverage src/utils"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@formatjs/intl-durationformat": "^0.9.2",
|
"@formatjs/intl-durationformat": "^0.9.2",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@simplewebauthn/types": "^12.0.0",
|
"@simplewebauthn/types": "^12.0.0",
|
||||||
"kysely": "^0.28.2",
|
"kysely": "^0.28.2",
|
||||||
|
"maxmind": "^5.0.3",
|
||||||
"pino": "^10.1.0",
|
"pino": "^10.1.0",
|
||||||
"postmark": "^4.0.5",
|
"postmark": "^4.0.5",
|
||||||
"zxcvbn": "^4.4.2"
|
"zxcvbn": "^4.4.2"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
|
import { generateSecureBase58Token } from "@reviq/utils";
|
||||||
import { base58 } from "@scure/base";
|
import { base58 } from "@scure/base";
|
||||||
|
|
||||||
// Re-export generateSecureBase58Token from shared utils
|
// Re-export for convenience
|
||||||
export { generateSecureBase58Token } from "@reviq/utils";
|
export { generateSecureBase58Token };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Token prefix for all RevIQ API tokens
|
* Token prefix for all RevIQ API tokens
|
||||||
@@ -59,10 +60,40 @@ export const generateSessionToken = (): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a device fingerprint (UUID v4)
|
* Device fingerprint prefix for new fingerprints
|
||||||
|
*/
|
||||||
|
export const DEVICE_FINGERPRINT_PREFIX = "device_";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a device fingerprint (base58 with device_ prefix)
|
||||||
*/
|
*/
|
||||||
export const generateDeviceFingerprint = (): string => {
|
export const generateDeviceFingerprint = (): string => {
|
||||||
return crypto.randomUUID();
|
return generateSecureBase58Token(DEVICE_FINGERPRINT_PREFIX);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a string is a valid device fingerprint.
|
||||||
|
* Accepts both new format (device_ prefix) and legacy UUIDs.
|
||||||
|
*/
|
||||||
|
export const isValidDeviceFingerprint = (fingerprint: string): boolean => {
|
||||||
|
// New format: device_ prefix with base58
|
||||||
|
if (fingerprint.startsWith(DEVICE_FINGERPRINT_PREFIX)) {
|
||||||
|
const base58Part = fingerprint.slice(DEVICE_FINGERPRINT_PREFIX.length);
|
||||||
|
if (base58Part.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
base58.decode(base58Part);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy format: UUID v4
|
||||||
|
const uuidRegex =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
return uuidRegex.test(fingerprint);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
222
apps/api-server/src/utils/geo.test.ts
Normal file
222
apps/api-server/src/utils/geo.test.ts
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { beforeEach, describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
_resetForTesting,
|
||||||
|
_setReaderForTesting,
|
||||||
|
extractClientIP,
|
||||||
|
getGeoInfo,
|
||||||
|
getUserAgent,
|
||||||
|
lookupGeoFromIP,
|
||||||
|
} from "./geo.js";
|
||||||
|
|
||||||
|
const createHeaders = (entries: Record<string, string>): Headers => {
|
||||||
|
return new Headers(entries);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("extractClientIP", () => {
|
||||||
|
test("extracts from CF-Connecting-IP", () => {
|
||||||
|
const headers = createHeaders({ "CF-Connecting-IP": "1.2.3.4" });
|
||||||
|
expect(extractClientIP(headers)).toBe("1.2.3.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts from True-Client-IP", () => {
|
||||||
|
const headers = createHeaders({ "True-Client-IP": "5.6.7.8" });
|
||||||
|
expect(extractClientIP(headers)).toBe("5.6.7.8");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts from X-Real-IP", () => {
|
||||||
|
const headers = createHeaders({ "X-Real-IP": "10.20.30.40" });
|
||||||
|
expect(extractClientIP(headers)).toBe("10.20.30.40");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts first IP from X-Forwarded-For", () => {
|
||||||
|
const headers = createHeaders({
|
||||||
|
"X-Forwarded-For": "100.1.2.3, 200.4.5.6, 10.0.0.1",
|
||||||
|
});
|
||||||
|
expect(extractClientIP(headers)).toBe("100.1.2.3");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extracts from X-Client-IP", () => {
|
||||||
|
const headers = createHeaders({ "X-Client-IP": "192.168.1.1" });
|
||||||
|
expect(extractClientIP(headers)).toBe("192.168.1.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prioritizes CF-Connecting-IP over others", () => {
|
||||||
|
const headers = createHeaders({
|
||||||
|
"CF-Connecting-IP": "1.1.1.1",
|
||||||
|
"True-Client-IP": "2.2.2.2",
|
||||||
|
"X-Real-IP": "3.3.3.3",
|
||||||
|
"X-Forwarded-For": "4.4.4.4",
|
||||||
|
"X-Client-IP": "5.5.5.5",
|
||||||
|
});
|
||||||
|
expect(extractClientIP(headers)).toBe("1.1.1.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns null when no IP headers present", () => {
|
||||||
|
expect(extractClientIP(createHeaders({}))).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("trims whitespace", () => {
|
||||||
|
const headers = createHeaders({ "CF-Connecting-IP": " 1.2.3.4 " });
|
||||||
|
expect(extractClientIP(headers)).toBe("1.2.3.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles IPv6", () => {
|
||||||
|
const headers = createHeaders({
|
||||||
|
"CF-Connecting-IP": "2001:0db8:85a3::8a2e:0370:7334",
|
||||||
|
});
|
||||||
|
expect(extractClientIP(headers)).toBe("2001:0db8:85a3::8a2e:0370:7334");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("lookupGeoFromIP", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns nulls when reader not initialized", () => {
|
||||||
|
expect(lookupGeoFromIP("8.8.8.8")).toEqual({
|
||||||
|
city: null,
|
||||||
|
region: null,
|
||||||
|
country: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns geo data from reader", () => {
|
||||||
|
_setReaderForTesting({
|
||||||
|
get: (ip: string) =>
|
||||||
|
ip === "8.8.8.8"
|
||||||
|
? {
|
||||||
|
city: { names: { en: "Mountain View" } },
|
||||||
|
subdivisions: [{ names: { en: "California" } }],
|
||||||
|
country: { iso_code: "US" },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(lookupGeoFromIP("8.8.8.8")).toEqual({
|
||||||
|
city: "Mountain View",
|
||||||
|
region: "California",
|
||||||
|
country: "US",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns nulls for unknown IP", () => {
|
||||||
|
_setReaderForTesting({ get: () => null } as never);
|
||||||
|
expect(lookupGeoFromIP("0.0.0.0")).toEqual({
|
||||||
|
city: null,
|
||||||
|
region: null,
|
||||||
|
country: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles partial geo data", () => {
|
||||||
|
_setReaderForTesting({
|
||||||
|
get: () => ({ country: { iso_code: "DE" } }),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(lookupGeoFromIP("1.2.3.4")).toEqual({
|
||||||
|
city: null,
|
||||||
|
region: null,
|
||||||
|
country: "DE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("handles reader errors gracefully", () => {
|
||||||
|
_setReaderForTesting({
|
||||||
|
get: () => {
|
||||||
|
throw new Error("Lookup failed");
|
||||||
|
},
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
expect(lookupGeoFromIP("invalid")).toEqual({
|
||||||
|
city: null,
|
||||||
|
region: null,
|
||||||
|
country: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getGeoInfo", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
_resetForTesting();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("uses Cloudflare geo headers when present", () => {
|
||||||
|
const headers = createHeaders({
|
||||||
|
"CF-Connecting-IP": "1.2.3.4",
|
||||||
|
"CF-IPCountry": "US",
|
||||||
|
"CF-IPCity": "San Francisco",
|
||||||
|
"CF-Region": "California",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGeoInfo(headers)).toEqual({
|
||||||
|
ip: "1.2.3.4",
|
||||||
|
city: "San Francisco",
|
||||||
|
region: "California",
|
||||||
|
country: "US",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("prefers CF headers over GeoIP lookup", () => {
|
||||||
|
_setReaderForTesting({
|
||||||
|
get: () => ({
|
||||||
|
city: { names: { en: "Wrong" } },
|
||||||
|
country: { iso_code: "XX" },
|
||||||
|
}),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const headers = createHeaders({
|
||||||
|
"CF-Connecting-IP": "1.2.3.4",
|
||||||
|
"CF-IPCountry": "US",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getGeoInfo(headers).country).toBe("US");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("falls back to GeoIP lookup when no CF geo headers", () => {
|
||||||
|
_setReaderForTesting({
|
||||||
|
get: () => ({
|
||||||
|
city: { names: { en: "Berlin" } },
|
||||||
|
subdivisions: [{ names: { en: "Berlin" } }],
|
||||||
|
country: { iso_code: "DE" },
|
||||||
|
}),
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const headers = createHeaders({ "X-Real-IP": "1.2.3.4" });
|
||||||
|
|
||||||
|
expect(getGeoInfo(headers)).toEqual({
|
||||||
|
ip: "1.2.3.4",
|
||||||
|
city: "Berlin",
|
||||||
|
region: "Berlin",
|
||||||
|
country: "DE",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns IP even without geo data", () => {
|
||||||
|
const headers = createHeaders({ "X-Forwarded-For": "203.0.113.50" });
|
||||||
|
const result = getGeoInfo(headers);
|
||||||
|
|
||||||
|
expect(result.ip).toBe("203.0.113.50");
|
||||||
|
expect(result.city).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns all nulls when no headers", () => {
|
||||||
|
expect(getGeoInfo(createHeaders({}))).toEqual({
|
||||||
|
ip: null,
|
||||||
|
city: null,
|
||||||
|
region: null,
|
||||||
|
country: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getUserAgent", () => {
|
||||||
|
test("returns User-Agent header", () => {
|
||||||
|
const headers = createHeaders({ "User-Agent": "Mozilla/5.0 Chrome/120" });
|
||||||
|
expect(getUserAgent(headers)).toBe("Mozilla/5.0 Chrome/120");
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns "Unknown" when missing', () => {
|
||||||
|
expect(getUserAgent(createHeaders({}))).toBe("Unknown");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { CityResponse, Reader } from "maxmind";
|
||||||
|
import { open } from "maxmind";
|
||||||
|
|
||||||
export interface GeoInfo {
|
export interface GeoInfo {
|
||||||
ip: string | null;
|
ip: string | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
@@ -5,37 +8,152 @@ export interface GeoInfo {
|
|||||||
country: string | null;
|
country: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Default path for GeoLite2-City database */
|
||||||
|
const DEFAULT_GEOIP_PATH = "/usr/share/GeoIP/GeoLite2-City.mmdb";
|
||||||
|
|
||||||
|
// Module-level reader instance
|
||||||
|
let geoReader: Reader<CityResponse> | null = null;
|
||||||
|
let initialized = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract geolocation info from request headers
|
* Initialize the GeoIP database reader.
|
||||||
* Supports Cloudflare headers in production, falls back to standard headers
|
* Uses GEOIP_DATABASE_PATH env var, defaults to /usr/share/GeoIP/GeoLite2-City.mmdb
|
||||||
* @param headers - Request headers
|
*/
|
||||||
* @returns Geolocation information extracted from headers
|
export const initGeoReader = async (): Promise<void> => {
|
||||||
|
if (initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = Bun.env.GEOIP_DATABASE_PATH ?? DEFAULT_GEOIP_PATH;
|
||||||
|
|
||||||
|
try {
|
||||||
|
geoReader = await open<CityResponse>(dbPath);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : "Unknown error";
|
||||||
|
console.warn(`GeoIP database not available at ${dbPath}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Test Helpers =====
|
||||||
|
// These are exported for testing only. Do not use in production code.
|
||||||
|
|
||||||
|
/** @internal Reset state for testing */
|
||||||
|
export const _resetForTesting = (): void => {
|
||||||
|
geoReader = null;
|
||||||
|
initialized = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @internal Set a mock reader for testing */
|
||||||
|
export const _setReaderForTesting = (
|
||||||
|
reader: Reader<CityResponse> | null,
|
||||||
|
): void => {
|
||||||
|
geoReader = reader;
|
||||||
|
initialized = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Public API =====
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract the client IP address from request headers.
|
||||||
|
* Checks headers in priority order:
|
||||||
|
* 1. CF-Connecting-IP (Cloudflare)
|
||||||
|
* 2. True-Client-IP (Cloudflare Enterprise / Akamai)
|
||||||
|
* 3. X-Real-IP (nginx, other proxies)
|
||||||
|
* 4. X-Forwarded-For (first IP in chain)
|
||||||
|
* 5. X-Client-IP (some proxies)
|
||||||
|
*/
|
||||||
|
export const extractClientIP = (headers: Headers): string | null => {
|
||||||
|
const cfIP = headers.get("CF-Connecting-IP");
|
||||||
|
if (cfIP) {
|
||||||
|
return cfIP.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const trueClientIP = headers.get("True-Client-IP");
|
||||||
|
if (trueClientIP) {
|
||||||
|
return trueClientIP.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const realIP = headers.get("X-Real-IP");
|
||||||
|
if (realIP) {
|
||||||
|
return realIP.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedFor = headers.get("X-Forwarded-For");
|
||||||
|
if (forwardedFor) {
|
||||||
|
const firstIP = forwardedFor.split(",")[0]?.trim();
|
||||||
|
if (firstIP) {
|
||||||
|
return firstIP;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientIP = headers.get("X-Client-IP");
|
||||||
|
if (clientIP) {
|
||||||
|
return clientIP.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Look up geo information from an IP address using the GeoIP database.
|
||||||
|
* Returns null values if the database isn't initialized or IP is unknown.
|
||||||
|
*/
|
||||||
|
export const lookupGeoFromIP = (
|
||||||
|
ip: string,
|
||||||
|
): { city: string | null; region: string | null; country: string | null } => {
|
||||||
|
if (!geoReader) {
|
||||||
|
return { city: null, region: null, country: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = geoReader.get(ip);
|
||||||
|
if (!result) {
|
||||||
|
return { city: null, region: null, country: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
city: result.city?.names.en ?? null,
|
||||||
|
region: result.subdivisions?.[0]?.names.en ?? null,
|
||||||
|
country: result.country?.iso_code ?? null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { city: null, region: null, country: null };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract geolocation info from request headers.
|
||||||
|
* Uses Cloudflare headers when available, falls back to GeoIP database lookup.
|
||||||
*/
|
*/
|
||||||
export const getGeoInfo = (headers: Headers): GeoInfo => {
|
export const getGeoInfo = (headers: Headers): GeoInfo => {
|
||||||
// Try Cloudflare headers first (production)
|
const ip = extractClientIP(headers);
|
||||||
const cfIP = headers.get("CF-Connecting-IP");
|
|
||||||
|
// Try Cloudflare geo headers first
|
||||||
const cfCountry = headers.get("CF-IPCountry");
|
const cfCountry = headers.get("CF-IPCountry");
|
||||||
const cfCity = headers.get("CF-IPCity");
|
const cfCity = headers.get("CF-IPCity");
|
||||||
const cfRegion = headers.get("CF-Region");
|
const cfRegion = headers.get("CF-Region");
|
||||||
|
|
||||||
// Fallback to X-Forwarded-For or X-Real-IP
|
if (cfCountry || cfCity || cfRegion) {
|
||||||
const forwardedFor = headers.get("X-Forwarded-For");
|
|
||||||
const realIP = headers.get("X-Real-IP");
|
|
||||||
|
|
||||||
const ip = cfIP ?? realIP ?? forwardedFor?.split(",")[0]?.trim() ?? null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ip,
|
ip,
|
||||||
city: cfCity ?? null,
|
city: cfCity ?? null,
|
||||||
region: cfRegion ?? null,
|
region: cfRegion ?? null,
|
||||||
country: cfCountry ?? null,
|
country: cfCountry ?? null,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to GeoIP database lookup
|
||||||
|
if (ip) {
|
||||||
|
return { ip, ...lookupGeoFromIP(ip) };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ip: null, city: null, region: null, country: null };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract User-Agent from request headers
|
* Extract User-Agent from request headers.
|
||||||
* @param headers - Request headers
|
|
||||||
* @returns User-Agent string or "Unknown" if not present
|
|
||||||
*/
|
*/
|
||||||
export const getUserAgent = (headers: Headers): string => {
|
export const getUserAgent = (headers: Headers): string => {
|
||||||
return headers.get("User-Agent") ?? "Unknown";
|
return headers.get("User-Agent") ?? "Unknown";
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
* Unit tests for passkey-helpers utility functions
|
* Unit tests for passkey-helpers utility functions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PasskeyRow } from "../../utils/passkey-helpers.js";
|
import type { PasskeyRow } from "./passkey-helpers.js";
|
||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import {
|
import {
|
||||||
base64urlToUint8Array,
|
base64urlToUint8Array,
|
||||||
formatPasskeyDate,
|
formatPasskeyDate,
|
||||||
parsePasskeyRow,
|
parsePasskeyRow,
|
||||||
uint8ArrayToBase64url,
|
uint8ArrayToBase64url,
|
||||||
} from "../../utils/passkey-helpers.js";
|
} from "./passkey-helpers.js";
|
||||||
|
|
||||||
describe("base64urlToUint8Array", () => {
|
describe("base64urlToUint8Array", () => {
|
||||||
test("converts base64url string to Uint8Array", () => {
|
test("converts base64url string to Uint8Array", () => {
|
||||||
7
bun.lock
7
bun.lock
@@ -27,6 +27,7 @@
|
|||||||
"@simplewebauthn/server": "^13.2.2",
|
"@simplewebauthn/server": "^13.2.2",
|
||||||
"@simplewebauthn/types": "^12.0.0",
|
"@simplewebauthn/types": "^12.0.0",
|
||||||
"kysely": "^0.28.2",
|
"kysely": "^0.28.2",
|
||||||
|
"maxmind": "^5.0.3",
|
||||||
"pino": "^10.1.0",
|
"pino": "^10.1.0",
|
||||||
"postmark": "^4.0.5",
|
"postmark": "^4.0.5",
|
||||||
"zxcvbn": "^4.4.2",
|
"zxcvbn": "^4.4.2",
|
||||||
@@ -846,6 +847,8 @@
|
|||||||
|
|
||||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||||
|
|
||||||
|
"maxmind": ["maxmind@5.0.3", "", { "dependencies": { "mmdb-lib": "3.0.1", "tiny-lru": "11.4.5" } }, "sha512-oMtZwLrsp0LcZehfYKIirtwKMBycMMqMA1/Dc9/BlUqIEtXO75mIzMJ3PYCV1Ji+BpoUCk+lTzRfh9c+ptGdyQ=="],
|
||||||
|
|
||||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||||
|
|
||||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
@@ -856,6 +859,8 @@
|
|||||||
|
|
||||||
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
||||||
|
|
||||||
|
"mmdb-lib": ["mmdb-lib@3.0.1", "", {}, "sha512-dyAyMR+cRykZd1mw5altC9f4vKpCsuywPwo8l/L5fKqDay2zmqT0mF/BvUoXnQiqGn+nceO914rkPKJoyFnGxA=="],
|
||||||
|
|
||||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
"mrmime": ["mrmime@2.0.1", "", {}, "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ=="],
|
||||||
@@ -1024,6 +1029,8 @@
|
|||||||
|
|
||||||
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
"thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="],
|
||||||
|
|
||||||
|
"tiny-lru": ["tiny-lru@11.4.5", "", {}, "sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw=="],
|
||||||
|
|
||||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||||
|
|
||||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||||
|
|||||||
Reference in New Issue
Block a user