Migrate from JSON to PostgreSQL, update SLA to 99.5%

- Replace JSON file storage with PostgreSQL (pg package)
- Add db.ts service for connection pool and schema init
- Rewrite keys.ts, verification.ts, usage.ts for async PostgreSQL
- Update all routes for async function signatures
- Add migration script (scripts/migrate-to-postgres.mjs)
- Update docker-compose.yml with DATABASE_* env vars
- Change SLA from 99.9% to 99.5% in landing page
This commit is contained in:
DocFast Bot 2026-02-15 10:18:25 +00:00
parent bb1881af61
commit e9d16bf2a3
13 changed files with 395 additions and 198 deletions

View file

@ -1,11 +1,5 @@
import { randomBytes, randomInt } from "crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, "../../data");
const DB_PATH = path.join(DATA_DIR, "verifications.json");
import pool from "./db.js";
export interface Verification {
email: string;
@ -23,79 +17,68 @@ export interface PendingVerification {
attempts: number;
}
let verifications: Verification[] = [];
let pendingVerifications: PendingVerification[] = [];
function ensureDataDir(): void {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
}
function load(): void {
ensureDataDir();
if (existsSync(DB_PATH)) {
try {
const data = JSON.parse(readFileSync(DB_PATH, "utf-8"));
// Support both old format (array) and new format (object)
if (Array.isArray(data)) {
verifications = data;
pendingVerifications = [];
} else {
verifications = data.verifications || [];
pendingVerifications = data.pendingVerifications || [];
}
} catch {
verifications = [];
pendingVerifications = [];
}
}
}
function save(): void {
ensureDataDir();
writeFileSync(DB_PATH, JSON.stringify({ verifications, pendingVerifications }, null, 2));
}
load();
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000;
const CODE_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
const CODE_EXPIRY_MS = 15 * 60 * 1000;
const MAX_ATTEMPTS = 3;
// Legacy token-based verification (keep for existing links)
export function createVerification(email: string, apiKey: string): Verification {
const existing = verifications.find(v => v.email === email && !v.verifiedAt);
if (existing) {
const age = Date.now() - new Date(existing.createdAt).getTime();
if (age < TOKEN_EXPIRY_MS) return existing;
verifications = verifications.filter(v => v !== existing);
export async function createVerification(email: string, apiKey: string): Promise<Verification> {
// Check for existing unexpired, unverified
const existing = await pool.query(
"SELECT * FROM verifications WHERE email = $1 AND verified_at IS NULL AND created_at > NOW() - INTERVAL '24 hours' LIMIT 1",
[email]
);
if (existing.rows.length > 0) {
const r = existing.rows[0];
return { email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at.toISOString(), verifiedAt: null };
}
const verification: Verification = {
email,
token: randomBytes(32).toString("hex"),
apiKey,
createdAt: new Date().toISOString(),
verifiedAt: null,
};
verifications.push(verification);
save();
return verification;
// Remove old unverified
await pool.query("DELETE FROM verifications WHERE email = $1 AND verified_at IS NULL", [email]);
const token = randomBytes(32).toString("hex");
const now = new Date().toISOString();
await pool.query(
"INSERT INTO verifications (email, token, api_key, created_at) VALUES ($1, $2, $3, $4)",
[email, token, apiKey, now]
);
return { email, token, apiKey, createdAt: now, verifiedAt: null };
}
export function verifyToken(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
const v = verifications.find(v => v.token === token);
// Synchronous wrapper — we'll make it async-compatible
// Actually need to keep sync for the GET /verify route. Use sync query workaround or refactor.
// For simplicity, we'll cache verifications in memory too.
return verifyTokenSync(token);
}
// In-memory cache for verifications (loaded on startup, updated on changes)
let verificationsCache: Verification[] = [];
export async function loadVerifications(): Promise<void> {
const result = await pool.query("SELECT * FROM verifications");
verificationsCache = result.rows.map((r) => ({
email: r.email,
token: r.token,
apiKey: r.api_key,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null,
}));
}
function verifyTokenSync(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
const v = verificationsCache.find((v) => v.token === token);
if (!v) return { status: "invalid" };
if (v.verifiedAt) return { status: "already_verified", verification: v };
const age = Date.now() - new Date(v.createdAt).getTime();
if (age > TOKEN_EXPIRY_MS) return { status: "expired" };
v.verifiedAt = new Date().toISOString();
save();
// Update DB async
pool.query("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch(console.error);
return { status: "ok", verification: v };
}
// New 6-digit code verification
export function createPendingVerification(email: string): PendingVerification {
// Remove any existing pending for this email
pendingVerifications = pendingVerifications.filter(p => p.email !== email);
export async function createPendingVerification(email: string): Promise<PendingVerification> {
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [email]);
const now = new Date();
const pending: PendingVerification = {
@ -105,47 +88,53 @@ export function createPendingVerification(email: string): PendingVerification {
expiresAt: new Date(now.getTime() + CODE_EXPIRY_MS).toISOString(),
attempts: 0,
};
pendingVerifications.push(pending);
save();
await pool.query(
"INSERT INTO pending_verifications (email, code, created_at, expires_at, attempts) VALUES ($1, $2, $3, $4, $5)",
[pending.email, pending.code, pending.createdAt, pending.expiresAt, pending.attempts]
);
return pending;
}
export function verifyCode(email: string, code: string): { status: "ok" | "invalid" | "expired" | "max_attempts" } {
export async function verifyCode(email: string, code: string): Promise<{ status: "ok" | "invalid" | "expired" | "max_attempts" }> {
const cleanEmail = email.trim().toLowerCase();
const pending = pendingVerifications.find(p => p.email === cleanEmail);
const result = await pool.query("SELECT * FROM pending_verifications WHERE email = $1", [cleanEmail]);
const pending = result.rows[0];
if (!pending) return { status: "invalid" };
if (new Date() > new Date(pending.expiresAt)) {
pendingVerifications = pendingVerifications.filter(p => p !== pending);
save();
if (new Date() > new Date(pending.expires_at)) {
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
return { status: "expired" };
}
if (pending.attempts >= MAX_ATTEMPTS) {
pendingVerifications = pendingVerifications.filter(p => p !== pending);
save();
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
return { status: "max_attempts" };
}
pending.attempts++;
await pool.query("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]);
if (pending.code !== code) {
save();
return { status: "invalid" };
}
// Success - remove pending
pendingVerifications = pendingVerifications.filter(p => p !== pending);
save();
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
return { status: "ok" };
}
export function isEmailVerified(email: string): boolean {
return verifications.some(v => v.email === email && v.verifiedAt !== null);
export async function isEmailVerified(email: string): Promise<boolean> {
const result = await pool.query(
"SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
[email]
);
return result.rows.length > 0;
}
export function getVerifiedApiKey(email: string): string | null {
const v = verifications.find(v => v.email === email && v.verifiedAt !== null);
return v?.apiKey ?? null;
export async function getVerifiedApiKey(email: string): Promise<string | null> {
const result = await pool.query(
"SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
[email]
);
return result.rows[0]?.api_key ?? null;
}