refactor(ban): drop ipinfo cgnat blast-radius guard (#3062)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-30 16:54:43 +02:00
1 parent ca719e7b5e
commit af49cd6cc4
45 files changed
+10 -2153

No files matched your search

@@ -1,60 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
const TABLE = 'ipinfo_cache';
const SELECT_CQL = `SELECT payload FROM ${TABLE} WHERE cache_key = :cache_key LIMIT 1;`;
const INSERT_WITH_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload) USING TTL :ttl;`;
const INSERT_DEFAULT_TTL_CQL = `INSERT INTO ${TABLE} (cache_key, payload) VALUES (:cache_key, :payload);`;
interface CassandraIpInfoCacheOptions {
client?: ICassandraClient;
getClient?: () => ICassandraClient;
}
export function createCassandraIpInfoCache(options: CassandraIpInfoCacheOptions): IpInfoCache {
return {
async get<T>(key: string): Promise<T | null> {
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return null;
}
const result = await client.execute({cql: SELECT_CQL, params: {cache_key: key}});
const row = result.first();
if (!row) return null;
const payload = row.get('payload');
if (typeof payload !== 'string') return null;
return JSON.parse(payload) as T;
} catch {
return null;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
let payload: string;
try {
payload = JSON.stringify(value);
} catch {
return;
}
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return;
}
if (ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0) {
await client.execute({
cql: INSERT_WITH_TTL_CQL,
params: {cache_key: key, payload, ttl: ttlSeconds},
});
} else {
await client.execute({
cql: INSERT_DEFAULT_TTL_CQL,
params: {cache_key: key, payload},
});
}
} catch {}
},
};
}
@@ -1,119 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import type {ICassandraClient} from '@pkgs/cassandra/src/Client';
import type {IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
const TABLE = 'ipinfo_requests_by_hour';
const INSERT_CQL = `INSERT INTO ${TABLE} (
bucket_date,
bucket_hour,
requested_at,
event_id,
source,
reason,
ip,
cache_key,
request_url,
http_status,
outcome,
available,
risk_note,
latency_ms,
response_ip,
country_code,
asn,
is_anonymous,
is_tor,
is_vpn,
is_proxy,
is_residential_proxy,
metadata_json
) VALUES (
:bucket_date,
:bucket_hour,
:requested_at,
:event_id,
:source,
:reason,
:ip,
:cache_key,
:request_url,
:http_status,
:outcome,
:available,
:risk_note,
:latency_ms,
:response_ip,
:country_code,
:asn,
:is_anonymous,
:is_tor,
:is_vpn,
:is_proxy,
:is_residential_proxy,
:metadata_json
);`;
interface CassandraIpInfoRequestAuditOptions {
client?: ICassandraClient;
getClient?: () => ICassandraClient;
}
export function createCassandraIpInfoRequestAuditLogger(
options: CassandraIpInfoRequestAuditOptions,
): IpInfoRequestAuditLogger {
return {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
try {
const client = options.client ?? options.getClient?.();
if (!client) {
return;
}
await client.execute({
cql: INSERT_CQL,
params: {
bucket_date: formatUtcDate(event.requestedAt),
bucket_hour: event.requestedAt.getUTCHours(),
requested_at: event.requestedAt,
event_id: randomUUID(),
source: event.source,
reason: event.reason,
ip: event.ip,
cache_key: event.cacheKey,
request_url: event.requestUrl,
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
asn: event.asnNumber,
is_anonymous: event.isAnonymous,
is_tor: event.isTor,
is_vpn: event.isVpn,
is_proxy: event.isProxy,
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
});
} catch {}
},
};
}
function formatUtcDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
if (!metadata || Object.keys(metadata).length === 0) {
return null;
}
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
-506
View File
@@ -1,506 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import {z} from 'zod';
const IPINFO_BASE_URL = 'https://api.ipinfo.io/lookup';
const FETCH_TIMEOUT_MS = 3000;
const CACHE_KEY_PREFIX = 'ipinfo:max:';
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/u;
const POSITIVE_CACHE_TTL_SECONDS = 7 * 24 * 60 * 60;
const NEGATIVE_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
const FAILURE_TTL_REQUEST_FAILED_SECONDS = 60;
const FAILURE_TTL_HTTP_ERROR_SECONDS = 300;
const FAILURE_TTL_QUOTA_SECONDS = 900;
const FAILURE_TTL_SCHEMA_MISMATCH_SECONDS = 600;
export interface IpInfoGeoBlock {
countryCode: string | null;
countryName: string | null;
continent: string | null;
continentCode: string | null;
region: string | null;
regionCode: string | null;
city: string | null;
postalCode: string | null;
timezone: string | null;
latitude: number | null;
longitude: number | null;
accuracyRadiusKm: number | null;
}
export interface IpInfoAsnBlock {
asn: string | null;
number: number | null;
name: string | null;
domain: string | null;
type: string | null;
}
export interface IpInfoMobileBlock {
name: string | null;
mcc: string | null;
mnc: string | null;
}
export interface IpInfoAnonymousBlock {
isAnonymous: boolean;
providerName: string | null;
isVpn: boolean;
isProxy: boolean;
isResidentialProxy: boolean;
isTor: boolean;
isRelay: boolean;
percentDaysSeen: number | null;
}
export interface IpInfoFlags {
isAnycast: boolean;
isHosting: boolean;
isMobile: boolean;
isSatellite: boolean;
}
export interface IpInfoLookupResult {
ip: string;
available: boolean;
note: string;
geo: IpInfoGeoBlock;
asn: IpInfoAsnBlock;
mobile: IpInfoMobileBlock;
anonymous: IpInfoAnonymousBlock;
flags: IpInfoFlags;
}
export interface IpInfoCache {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttlSeconds?: number): Promise<void>;
}
export interface CachedIpInfoFailure extends IpInfoLookupResult {
cachedFailure: true;
failureOutcome: 'http_error' | 'request_failed' | 'schema_mismatch';
failureHttpStatus: number | null;
cachedAtMs: number;
}
export function isCachedIpInfoFailure(value: unknown): value is CachedIpInfoFailure {
return typeof value === 'object' && value !== null && (value as {available?: unknown}).available === false;
}
function failureCacheTtlSeconds(outcome: CachedIpInfoFailure['failureOutcome'], httpStatus: number | null): number {
if (outcome === 'request_failed') return FAILURE_TTL_REQUEST_FAILED_SECONDS;
if (outcome === 'schema_mismatch') return FAILURE_TTL_SCHEMA_MISMATCH_SECONDS;
if (httpStatus === 402 || httpStatus === 403 || httpStatus === 429) return FAILURE_TTL_QUOTA_SECONDS;
return FAILURE_TTL_HTTP_ERROR_SECONDS;
}
export interface IpInfoLookupContext {
source?: string;
reason?: string;
metadata?: Record<string, string | number | boolean | null>;
}
export interface IpInfoRequestAuditEvent {
requestedAt: Date;
ip: string;
cacheKey: string;
source: string;
reason: string | null;
metadata?: Record<string, string | number | boolean | null>;
outcome: 'http_success' | 'http_error' | 'request_failed' | 'schema_mismatch';
httpStatus: number | null;
available: boolean;
note: string;
latencyMs: number;
requestUrl: string;
responseIp: string | null;
countryCode: string | null;
asnNumber: number | null;
isAnonymous: boolean;
isTor: boolean;
isVpn: boolean;
isProxy: boolean;
isResidentialProxy: boolean;
}
export interface IpInfoRequestAuditLogger {
record(event: IpInfoRequestAuditEvent): Promise<void>;
}
interface IpInfoServiceContext {
apiKey: string;
cache: IpInfoCache;
auditLogger?: IpInfoRequestAuditLogger;
}
export interface IpInfoService {
lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult>;
}
const IpInfoDateSchema = z.string().regex(ISO_DATE_REGEX);
const RawIpInfoGeoSchema = z.object({
city: z.string().optional(),
region: z.string().optional(),
region_code: z.string().optional(),
country: z.string().optional(),
country_code: z.string().optional(),
continent: z.string().optional(),
continent_code: z.string().optional(),
latitude: z.number().optional(),
longitude: z.number().optional(),
timezone: z.string().optional(),
postal_code: z.string().optional(),
dma_code: z.string().optional(),
geoname_id: z.string().optional(),
radius: z.number().int().optional(),
last_changed: IpInfoDateSchema.optional(),
});
const RawIpInfoAsSchema = z.object({
asn: z.string().optional(),
name: z.string().optional(),
domain: z.string().optional(),
type: z.string().optional(),
last_changed: IpInfoDateSchema.optional(),
});
const RawIpInfoMobileSchema = z.object({
name: z.string().optional(),
mcc: z.string().optional(),
mnc: z.string().optional(),
});
const RawIpInfoAnonymousSchema = z.object({
name: z.string().optional(),
last_seen: IpInfoDateSchema.optional(),
percent_days_seen: z.number().int().optional(),
is_proxy: z.boolean().optional(),
is_relay: z.boolean().optional(),
is_tor: z.boolean().optional(),
is_vpn: z.boolean().optional(),
is_res_proxy: z.boolean().optional(),
});
const RawIpInfoResponseSchema = z.object({
ip: z.string(),
hostname: z.string().optional(),
geo: RawIpInfoGeoSchema,
as: RawIpInfoAsSchema,
mobile: RawIpInfoMobileSchema.optional(),
anonymous: RawIpInfoAnonymousSchema,
is_anonymous: z.boolean().optional(),
is_anycast: z.boolean().optional(),
is_hosting: z.boolean().optional(),
is_mobile: z.boolean().optional(),
is_satellite: z.boolean().optional(),
});
type RawIpInfoResponse = z.infer<typeof RawIpInfoResponseSchema>;
export function createIpInfoService(ctx: IpInfoServiceContext): IpInfoService {
const inflight: Map<string, Promise<IpInfoLookupResult>> = new Map();
return {
async lookup(ip: string, context?: IpInfoLookupContext): Promise<IpInfoLookupResult> {
const cacheKey = `${CACHE_KEY_PREFIX}${getSameIpDecisionKey(ip) ?? ip}`;
const cached = await ctx.cache.get<IpInfoLookupResult>(cacheKey);
if (cached !== null) {
if (isCachedIpInfoFailure(cached)) {
return unavailable(ip, cached.note);
}
return {...cached, ip};
}
const existing = inflight.get(cacheKey);
if (existing) {
const result = await existing;
return {...result, ip};
}
const requestedAt = new Date();
const startedAt = Date.now();
const requestUrl = `${IPINFO_BASE_URL}/${encodeURIComponent(ip)}`;
const fetchUrl = `${requestUrl}?token=${encodeURIComponent(ctx.apiKey)}`;
const finalize = async (params: {
result: IpInfoLookupResult;
outcome: IpInfoRequestAuditEvent['outcome'];
httpStatus: number | null;
}): Promise<IpInfoLookupResult> => {
await ctx.auditLogger
?.record({
requestedAt,
ip,
cacheKey,
source: context?.source ?? 'unknown',
reason: context?.reason ?? null,
metadata: context?.metadata,
outcome: params.outcome,
httpStatus: params.httpStatus,
available: params.result.available,
note: params.result.note,
latencyMs: Date.now() - startedAt,
requestUrl,
responseIp: params.result.available ? params.result.ip : null,
countryCode: params.result.geo.countryCode,
asnNumber: params.result.asn.number,
isAnonymous: params.result.anonymous.isAnonymous,
isTor: params.result.anonymous.isTor,
isVpn: params.result.anonymous.isVpn,
isProxy: params.result.anonymous.isProxy,
isResidentialProxy: params.result.anonymous.isResidentialProxy,
})
.catch(() => {});
return params.result;
};
const performLookup = async (): Promise<IpInfoLookupResult> => {
const finalizeFailure = async (params: {
result: IpInfoLookupResult;
outcome: CachedIpInfoFailure['failureOutcome'];
httpStatus: number | null;
}): Promise<IpInfoLookupResult> => {
const entry: CachedIpInfoFailure = {
...params.result,
cachedFailure: true,
failureOutcome: params.outcome,
failureHttpStatus: params.httpStatus,
cachedAtMs: Date.now(),
};
await ctx.cache
.set(cacheKey, entry, failureCacheTtlSeconds(params.outcome, params.httpStatus))
.catch(() => {});
return finalize(params);
};
const controller = new AbortController();
const timer = setTimeout(() => {
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'));
}, FETCH_TIMEOUT_MS);
timer.unref();
let payload: unknown;
try {
const res = await fetch(fetchUrl, {
signal: controller.signal,
headers: {Accept: 'application/json'},
});
if (!res.ok) {
return finalizeFailure({
result: unavailable(ip, `IPInfo HTTP ${res.status}`),
outcome: 'http_error',
httpStatus: res.status,
});
}
payload = await res.json();
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
return finalizeFailure({
result: unavailable(ip, `IPInfo request failed: ${detail}`),
outcome: 'request_failed',
httpStatus: null,
});
} finally {
clearTimeout(timer);
controller.abort();
}
const parsedResponse = RawIpInfoResponseSchema.safeParse(payload);
if (!parsedResponse.success) {
return finalizeFailure({
result: unavailable(ip, formatSchemaMismatch(parsedResponse.error)),
outcome: 'schema_mismatch',
httpStatus: 200,
});
}
const result = parseIpInfoResponse(parsedResponse.data);
const ttl = result.anonymous.isAnonymous ? POSITIVE_CACHE_TTL_SECONDS : NEGATIVE_CACHE_TTL_SECONDS;
await ctx.cache.set(cacheKey, result, ttl).catch(() => {});
return finalize({
result,
outcome: 'http_success',
httpStatus: 200,
});
};
const promise: Promise<IpInfoLookupResult> = performLookup().finally(() => {
if (inflight.get(cacheKey) === promise) {
inflight.delete(cacheKey);
}
});
inflight.set(cacheKey, promise);
return promise;
},
};
}
export function createUnavailableIpInfoService(reason = 'IPInfo not configured'): IpInfoService {
return {
async lookup(ip: string): Promise<IpInfoLookupResult> {
return unavailable(ip, reason);
},
};
}
function unavailable(ip: string, reason: string): IpInfoLookupResult {
return {
ip,
available: false,
note: reason,
geo: emptyGeo(),
asn: emptyAsn(),
mobile: emptyMobile(),
anonymous: emptyAnonymous(),
flags: emptyFlags(),
};
}
function emptyGeo(): IpInfoGeoBlock {
return {
countryCode: null,
countryName: null,
continent: null,
continentCode: null,
region: null,
regionCode: null,
city: null,
postalCode: null,
timezone: null,
latitude: null,
longitude: null,
accuracyRadiusKm: null,
};
}
function emptyAsn(): IpInfoAsnBlock {
return {asn: null, number: null, name: null, domain: null, type: null};
}
function emptyMobile(): IpInfoMobileBlock {
return {name: null, mcc: null, mnc: null};
}
function emptyAnonymous(): IpInfoAnonymousBlock {
return {
isAnonymous: false,
providerName: null,
isVpn: false,
isProxy: false,
isResidentialProxy: false,
isTor: false,
isRelay: false,
percentDaysSeen: null,
};
}
function emptyFlags(): IpInfoFlags {
return {isAnycast: false, isHosting: false, isMobile: false, isSatellite: false};
}
function parseIpInfoResponse(raw: RawIpInfoResponse): IpInfoLookupResult {
const geo = raw.geo;
const anon = raw.anonymous;
const isAnonymous =
raw.is_anonymous === true ||
anon.is_res_proxy === true ||
anon.is_vpn === true ||
anon.is_proxy === true ||
anon.is_tor === true ||
anon.is_relay === true;
return {
ip: raw.ip,
available: true,
note: describeAnonymity(isAnonymous, anon),
geo: {
countryCode: normalizeCountryCode(geo.country_code),
countryName: geo.country ?? null,
continent: geo?.continent ?? null,
continentCode: normalizeContinentCode(geo.continent_code),
region: geo.region ?? null,
regionCode: normalizeRegionCode(geo.region_code),
city: geo.city ?? null,
postalCode: geo.postal_code ?? null,
timezone: geo.timezone ?? null,
latitude: normalizeCoordinate(geo.latitude),
longitude: normalizeCoordinate(geo.longitude),
accuracyRadiusKm: typeof geo?.radius === 'number' && Number.isFinite(geo.radius) ? geo.radius : null,
},
asn: parseAsnBlock(raw.as),
mobile: {
name: raw.mobile?.name ?? null,
mcc: raw.mobile?.mcc ?? null,
mnc: raw.mobile?.mnc ?? null,
},
anonymous: {
isAnonymous,
providerName: anon?.name ?? null,
isVpn: anon?.is_vpn === true,
isProxy: anon?.is_proxy === true,
isResidentialProxy: anon?.is_res_proxy === true,
isTor: anon?.is_tor === true,
isRelay: anon?.is_relay === true,
percentDaysSeen: typeof anon?.percent_days_seen === 'number' ? anon.percent_days_seen : null,
},
flags: {
isAnycast: raw.is_anycast === true,
isHosting: raw.is_hosting === true,
isMobile: raw.is_mobile === true,
isSatellite: raw.is_satellite === true,
},
};
}
function formatSchemaMismatch(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) {
return 'IPInfo response schema mismatch';
}
const path = issue.path.length > 0 ? issue.path.join('.') : '<root>';
return `IPInfo response schema mismatch at ${path}: ${issue.message}`;
}
function parseAsnBlock(as: RawIpInfoResponse['as']): IpInfoAsnBlock {
const raw = as?.asn ?? null;
const numeric = raw ? Number(raw.replace(/^AS/i, '')) : Number.NaN;
return {
asn: raw,
number: Number.isFinite(numeric) ? numeric : null,
name: as?.name ?? null,
domain: as?.domain ?? null,
type: as?.type ?? null,
};
}
function describeAnonymity(isAnonymous: boolean, anon: RawIpInfoResponse['anonymous']): string {
if (!isAnonymous) {
return 'IPInfo: IP is not anonymous';
}
if (!anon) {
return 'IPInfo: anonymous IP';
}
const flags: Array<string> = [];
if (anon.is_res_proxy) flags.push('residential proxy');
if (anon.is_vpn) flags.push('VPN');
if (anon.is_proxy) flags.push('proxy');
if (anon.is_tor) flags.push('Tor');
if (anon.is_relay) flags.push('relay');
const provider = anon.name ? ` (provider: ${anon.name})` : '';
const seen = anon.percent_days_seen != null ? `, seen ${anon.percent_days_seen}% of days` : '';
return `IPInfo: anonymous IP${provider} — ${flags.join(', ')}${seen}`;
}
function normalizeCountryCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
}
function normalizeContinentCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return /^[A-Z]{2}$/u.test(normalized) ? normalized : null;
}
function normalizeRegionCode(value: string | undefined): string | null {
if (!value) {
return null;
}
const normalized = value.trim().toUpperCase();
return normalized.length > 0 ? normalized : null;
}
function normalizeCoordinate(value: number | undefined): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
@@ -1,153 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {randomUUID} from 'node:crypto';
import type {IpInfoCache, IpInfoRequestAuditEvent, IpInfoRequestAuditLogger} from '@pkgs/geoip/src/IpInfoService';
import {type IPostgresClient, quoteIdentifier} from '@pkgs/postgres/src/Client';
interface PostgresIpInfoOptions {
client?: IPostgresClient;
getClient?: () => IPostgresClient;
onError?: (error: unknown, operation: string) => void;
}
const VALUE_SEPARATOR = '\u001f';
export const IPINFO_CACHE_TTL_SECONDS = 14 * 24 * 60 * 60;
export const IPINFO_REQUEST_AUDIT_TTL_SECONDS = 90 * 24 * 60 * 60;
function getClient(options: PostgresIpInfoOptions): IPostgresClient | null {
return options.client ?? options.getClient?.() ?? null;
}
function valueKey(value: unknown): string {
return JSON.stringify(value);
}
function rowKey(values: ReadonlyArray<unknown>): string {
return values.map(valueKey).join(VALUE_SEPARATOR);
}
function table(client: IPostgresClient): string {
return quoteIdentifier(client.kvTable());
}
async function upsertKvRow(
client: IPostgresClient,
tableName: string,
partitionKey: string,
key: string,
row: Record<string, unknown>,
ttlSeconds: number,
): Promise<void> {
const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
await client.query(
`INSERT INTO ${table(client)} (table_name, partition_key, row_key, row_data, expires_at, updated_at)
VALUES ($1, $2, $3, $4::jsonb, $5, now())
ON CONFLICT (table_name, row_key)
DO UPDATE SET partition_key = EXCLUDED.partition_key, row_data = EXCLUDED.row_data, expires_at = EXCLUDED.expires_at, updated_at = now()`,
[tableName, partitionKey, key, JSON.stringify(row), expiresAt],
);
}
export function createPostgresIpInfoCache(options: PostgresIpInfoOptions): IpInfoCache {
return {
async get<T>(key: string): Promise<T | null> {
try {
const client = getClient(options);
if (!client) return null;
const result = await client.query<{row_data: {payload?: string}}>(
`SELECT row_data FROM ${table(client)} WHERE table_name = $1 AND row_key = $2 AND (expires_at IS NULL OR expires_at > now()) LIMIT 1`,
['ipinfo_cache', rowKey([key])],
);
const payload = result.rows[0]?.row_data?.payload;
return typeof payload === 'string' ? (JSON.parse(payload) as T) : null;
} catch (error) {
options.onError?.(error, 'ipinfo_cache_get');
return null;
}
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
let payload: string;
try {
payload = JSON.stringify(value);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_serialize');
return;
}
try {
const client = getClient(options);
if (!client) return;
await upsertKvRow(
client,
'ipinfo_cache',
rowKey([key]),
rowKey([key]),
{cache_key: key, payload},
ttlSeconds != null && Number.isFinite(ttlSeconds) && ttlSeconds > 0 ? ttlSeconds : IPINFO_CACHE_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_cache_set');
}
},
};
}
export function createPostgresIpInfoRequestAuditLogger(options: PostgresIpInfoOptions): IpInfoRequestAuditLogger {
return {
async record(event: IpInfoRequestAuditEvent): Promise<void> {
try {
const client = getClient(options);
if (!client) return;
const bucketDate = formatUtcDate(event.requestedAt);
const bucketHour = event.requestedAt.getUTCHours();
const eventId = randomUUID();
await upsertKvRow(
client,
'ipinfo_requests_by_hour',
rowKey([bucketDate, bucketHour]),
rowKey([bucketDate, bucketHour, event.requestedAt.toISOString(), eventId]),
{
bucket_date: bucketDate,
bucket_hour: bucketHour,
requested_at: event.requestedAt.toISOString(),
event_id: eventId,
source: event.source,
reason: event.reason,
ip: event.ip,
cache_key: event.cacheKey,
request_url: event.requestUrl,
http_status: event.httpStatus,
outcome: event.outcome,
available: event.available,
risk_note: event.note,
latency_ms: event.latencyMs,
response_ip: event.responseIp,
country_code: event.countryCode,
asn: event.asnNumber,
is_anonymous: event.isAnonymous,
is_tor: event.isTor,
is_vpn: event.isVpn,
is_proxy: event.isProxy,
is_residential_proxy: event.isResidentialProxy,
metadata_json: serializeMetadata(event.metadata),
},
IPINFO_REQUEST_AUDIT_TTL_SECONDS,
);
} catch (error) {
options.onError?.(error, 'ipinfo_request_audit_record');
}
},
};
}
function formatUtcDate(value: Date): string {
return value.toISOString().slice(0, 10);
}
function serializeMetadata(metadata: IpInfoRequestAuditEvent['metadata']): string | null {
if (!metadata || Object.keys(metadata).length === 0) return null;
try {
return JSON.stringify(metadata);
} catch {
return null;
}
}
@@ -1,35 +0,0 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {IpInfoCache} from '@pkgs/geoip/src/IpInfoService';
const DEFAULT_HOT_TTL_SECONDS = 10 * 60;
interface TieredIpInfoCacheOptions {
hot: IpInfoCache;
cold: IpInfoCache;
hotTtlSeconds?: number;
skipColdWrite?: (value: unknown) => boolean;
}
export function createTieredIpInfoCache(opts: TieredIpInfoCacheOptions): IpInfoCache {
const hotTtl = opts.hotTtlSeconds ?? DEFAULT_HOT_TTL_SECONDS;
return {
async get<T>(key: string): Promise<T | null> {
const hit = await opts.hot.get<T>(key).catch(() => null);
if (hit !== null) return hit;
const cold = await opts.cold.get<T>(key).catch(() => null);
if (cold === null) return null;
if (opts.skipColdWrite?.(cold) === true) return cold;
void opts.hot.set(key, cold, hotTtl).catch(() => {});
return cold;
},
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
const effectiveHotTtl = Math.max(1, Math.min(hotTtl, ttlSeconds ?? hotTtl));
const writes: Array<Promise<void>> = [opts.hot.set(key, value, effectiveHotTtl).catch(() => {})];
if (opts.skipColdWrite?.(value) !== true) {
writes.push(opts.cold.set(key, value, ttlSeconds).catch(() => {}));
}
await Promise.all(writes);
},
};
}