192 lines
5.2 KiB
TypeScript
192 lines
5.2 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import type {
|
|
SmsPhoneLogAction,
|
|
SmsPhoneLogEntry,
|
|
SmsPhoneLogResult,
|
|
} from "../models/smsRegistration.js";
|
|
import { getDatabase } from "./sqlite.js";
|
|
|
|
type DbRow = {
|
|
id: string;
|
|
user_id: string;
|
|
phone_e164: string | null;
|
|
phone_hash: string;
|
|
action: string;
|
|
result: string;
|
|
detail: string | null;
|
|
jwt_hash: string | null;
|
|
provider_message_id: string | null;
|
|
created_at: string;
|
|
};
|
|
|
|
const ROW_COLUMNS =
|
|
"id, user_id, phone_e164, phone_hash, action, result, detail, " +
|
|
"jwt_hash, provider_message_id, created_at";
|
|
|
|
function toRecord(row: DbRow): SmsPhoneLogEntry {
|
|
return {
|
|
id: row.id,
|
|
userId: row.user_id,
|
|
phoneE164: row.phone_e164 ?? undefined,
|
|
phoneHash: row.phone_hash,
|
|
action: row.action as SmsPhoneLogAction,
|
|
result: row.result as SmsPhoneLogResult,
|
|
detail: row.detail ?? undefined,
|
|
jwtHash: row.jwt_hash ?? undefined,
|
|
providerMessageId: row.provider_message_id ?? undefined,
|
|
createdAt: row.created_at,
|
|
};
|
|
}
|
|
|
|
export type SmsPhoneLogInput = {
|
|
userId: string;
|
|
phoneE164?: string;
|
|
phoneHash: string;
|
|
action: SmsPhoneLogAction;
|
|
result: SmsPhoneLogResult;
|
|
detail?: string;
|
|
jwtHash?: string;
|
|
providerMessageId?: string;
|
|
};
|
|
|
|
/** Append-only record of every phone action, plus the counts the throttles read. */
|
|
export const smsPhoneLogDb = {
|
|
async append(input: SmsPhoneLogInput): Promise<SmsPhoneLogEntry> {
|
|
const now = new Date().toISOString();
|
|
const id = randomUUID();
|
|
getDatabase()
|
|
.prepare(
|
|
`
|
|
INSERT INTO sms_phone_log (
|
|
id, user_id, phone_e164, phone_hash, action, result,
|
|
detail, jwt_hash, provider_message_id, created_at
|
|
) VALUES (
|
|
@id, @user_id, @phone_e164, @phone_hash, @action, @result,
|
|
@detail, @jwt_hash, @provider_message_id, @created_at
|
|
)
|
|
`
|
|
)
|
|
.run({
|
|
id,
|
|
user_id: input.userId,
|
|
phone_e164: input.phoneE164 ?? null,
|
|
phone_hash: input.phoneHash,
|
|
action: input.action,
|
|
result: input.result,
|
|
detail: input.detail ?? null,
|
|
jwt_hash: input.jwtHash ?? null,
|
|
provider_message_id: input.providerMessageId ?? null,
|
|
created_at: now,
|
|
});
|
|
return {
|
|
id,
|
|
userId: input.userId,
|
|
phoneE164: input.phoneE164,
|
|
phoneHash: input.phoneHash,
|
|
action: input.action,
|
|
result: input.result,
|
|
detail: input.detail,
|
|
jwtHash: input.jwtHash,
|
|
providerMessageId: input.providerMessageId,
|
|
createdAt: now,
|
|
};
|
|
},
|
|
|
|
async listByUserId(
|
|
userId: string,
|
|
limit = 100
|
|
): Promise<SmsPhoneLogEntry[]> {
|
|
const rows = getDatabase()
|
|
.prepare(
|
|
`
|
|
SELECT ${ROW_COLUMNS} FROM sms_phone_log
|
|
WHERE user_id = ?
|
|
ORDER BY created_at DESC
|
|
LIMIT ?
|
|
`
|
|
)
|
|
.all(userId, limit) as DbRow[];
|
|
return rows.map(toRecord);
|
|
},
|
|
|
|
/**
|
|
* Counted from phone_hash, not user_id: a per-identity counter is defeated by
|
|
* minting more identities, so this throttle is deliberately cross-DID.
|
|
*/
|
|
async countByPhoneHashSince(
|
|
phoneHash: string,
|
|
actions: SmsPhoneLogAction[],
|
|
sinceIso: string
|
|
): Promise<number> {
|
|
if (actions.length === 0) return 0;
|
|
const placeholders = actions.map(() => "?").join(", ");
|
|
const row = getDatabase()
|
|
.prepare(
|
|
`
|
|
SELECT COUNT(*) AS n FROM sms_phone_log
|
|
WHERE phone_hash = ? AND created_at >= ?
|
|
AND action IN (${placeholders})
|
|
`
|
|
)
|
|
.get(phoneHash, sinceIso, ...actions) as { n: number };
|
|
return row.n;
|
|
},
|
|
|
|
/** The backstop on daily alert sends: one handset, one identity, one window. */
|
|
async countByUserAndPhoneHashSince(
|
|
userId: string,
|
|
phoneHash: string,
|
|
actions: SmsPhoneLogAction[],
|
|
sinceIso: string
|
|
): Promise<number> {
|
|
if (actions.length === 0) return 0;
|
|
const placeholders = actions.map(() => "?").join(", ");
|
|
const row = getDatabase()
|
|
.prepare(
|
|
`
|
|
SELECT COUNT(*) AS n FROM sms_phone_log
|
|
WHERE user_id = ? AND phone_hash = ? AND created_at >= ?
|
|
AND action IN (${placeholders})
|
|
`
|
|
)
|
|
.get(userId, phoneHash, sinceIso, ...actions) as { n: number };
|
|
return row.n;
|
|
},
|
|
|
|
async countByUserSince(
|
|
userId: string,
|
|
actions: SmsPhoneLogAction[],
|
|
sinceIso: string
|
|
): Promise<number> {
|
|
if (actions.length === 0) return 0;
|
|
const placeholders = actions.map(() => "?").join(", ");
|
|
const row = getDatabase()
|
|
.prepare(
|
|
`
|
|
SELECT COUNT(*) AS n FROM sms_phone_log
|
|
WHERE user_id = ? AND created_at >= ?
|
|
AND action IN (${placeholders})
|
|
`
|
|
)
|
|
.get(userId, sinceIso, ...actions) as { n: number };
|
|
return row.n;
|
|
},
|
|
|
|
/**
|
|
* Forget whose number it was, keep what happened. DELETE nulls phone_e164 for
|
|
* one DID's rows; phone_hash and the action history are left intact.
|
|
*/
|
|
async scrubPhoneNumber(userId: string, phoneE164: string): Promise<number> {
|
|
const result = getDatabase()
|
|
.prepare(
|
|
`
|
|
UPDATE sms_phone_log
|
|
SET phone_e164 = NULL
|
|
WHERE user_id = ? AND phone_e164 = ?
|
|
`
|
|
)
|
|
.run(userId, phoneE164);
|
|
return result.changes;
|
|
},
|
|
};
|