#!/usr/bin/env bun /** * Backfill migration: encrypt PII fields at rest * * Usage: docker exec encryptid bun run src/encryptid/migrations/encrypt-pii.ts * * Idempotent — only processes rows where _enc IS NULL. * Batched for notifications (500/batch), unbatched for smaller tables. */ import postgres from 'postgres'; import { encryptField, hashForLookup } from '../server-crypto'; const DATABASE_URL = process.env.DATABASE_URL; if (!DATABASE_URL) { console.error('DATABASE_URL required'); process.exit(1); } const sql = postgres(DATABASE_URL, { max: 5 }); async function backfillUsers() { const rows = await sql` SELECT id, email, profile_email, bio, avatar_url, wallet_address, up_address FROM users WHERE email_enc IS NULL AND (email IS NOT NULL OR bio IS NOT NULL OR avatar_url IS NOT NULL OR wallet_address IS NOT NULL OR up_address IS NOT NULL) `; console.log(`[users] ${rows.length} rows to backfill`); for (const row of rows) { const [emailEnc, emailHash, profileEmailEnc, bioEnc, avatarUrlEnc, walletEnc, upEnc, upHash] = await Promise.all([ encryptField(row.email), row.email ? hashForLookup(row.email) : null, encryptField(row.profile_email), encryptField(row.bio), encryptField(row.avatar_url), encryptField(row.wallet_address), encryptField(row.up_address), row.up_address ? hashForLookup(row.up_address) : null, ]); await sql` UPDATE users SET email_enc = ${emailEnc}, email_hash = ${emailHash}, profile_email_enc = ${profileEmailEnc}, bio_enc = ${bioEnc}, avatar_url_enc = ${avatarUrlEnc}, wallet_address_enc = ${walletEnc}, up_address_enc = ${upEnc}, up_address_hash = ${upHash} WHERE id = ${row.id} `; } console.log(`[users] done`); } async function backfillGuardians() { const rows = await sql` SELECT id, name, email FROM guardians WHERE name_enc IS NULL `; console.log(`[guardians] ${rows.length} rows to backfill`); for (const row of rows) { const [nameEnc, emailEnc] = await Promise.all([ encryptField(row.name), encryptField(row.email), ]); await sql`UPDATE guardians SET name_enc = ${nameEnc}, email_enc = ${emailEnc} WHERE id = ${row.id}`; } console.log(`[guardians] done`); } async function backfillIdentityInvites() { const rows = await sql` SELECT id, email, message FROM identity_invites WHERE email_enc IS NULL `; console.log(`[identity_invites] ${rows.length} rows to backfill`); for (const row of rows) { const [emailEnc, emailHash, messageEnc] = await Promise.all([ encryptField(row.email), hashForLookup(row.email), encryptField(row.message), ]); await sql`UPDATE identity_invites SET email_enc = ${emailEnc}, email_hash = ${emailHash}, message_enc = ${messageEnc} WHERE id = ${row.id}`; } console.log(`[identity_invites] done`); } async function backfillSpaceInvites() { const rows = await sql` SELECT id, email FROM space_invites WHERE email_enc IS NULL AND email IS NOT NULL `; console.log(`[space_invites] ${rows.length} rows to backfill`); for (const row of rows) { const emailEnc = await encryptField(row.email); await sql`UPDATE space_invites SET email_enc = ${emailEnc} WHERE id = ${row.id}`; } console.log(`[space_invites] done`); } async function backfillNotifications() { const BATCH = 500; let offset = 0; let total = 0; while (true) { const rows = await sql` SELECT id, title, body, actor_username FROM notifications WHERE title_enc IS NULL ORDER BY created_at LIMIT ${BATCH} OFFSET ${offset} `; if (rows.length === 0) break; total += rows.length; console.log(`[notifications] processing batch at offset ${offset} (${rows.length} rows)`); await Promise.all(rows.map(async (row) => { const [titleEnc, bodyEnc, actorEnc] = await Promise.all([ encryptField(row.title), encryptField(row.body), encryptField(row.actor_username), ]); await sql`UPDATE notifications SET title_enc = ${titleEnc}, body_enc = ${bodyEnc}, actor_username_enc = ${actorEnc} WHERE id = ${row.id}`; })); offset += BATCH; } console.log(`[notifications] ${total} rows done`); } async function backfillFundClaims() { const rows = await sql` SELECT id, email, wallet_address FROM fund_claims WHERE email_enc IS NULL AND (email IS NOT NULL OR wallet_address IS NOT NULL) `; console.log(`[fund_claims] ${rows.length} rows to backfill`); for (const row of rows) { const [emailEnc, emailHmac, walletEnc] = await Promise.all([ encryptField(row.email), row.email ? hashForLookup(row.email) : null, encryptField(row.wallet_address), ]); await sql`UPDATE fund_claims SET email_enc = ${emailEnc}, email_hmac = ${emailHmac}, wallet_address_enc = ${walletEnc} WHERE id = ${row.id}`; } console.log(`[fund_claims] done`); } async function main() { console.log('=== PII Encryption Backfill ==='); console.log(`Time: ${new Date().toISOString()}`); await backfillUsers(); await backfillGuardians(); await backfillIdentityInvites(); await backfillSpaceInvites(); await backfillNotifications(); await backfillFundClaims(); // Verification counts const [usersLeft] = await sql`SELECT COUNT(*)::int as c FROM users WHERE email IS NOT NULL AND email_enc IS NULL`; const [guardiansLeft] = await sql`SELECT COUNT(*)::int as c FROM guardians WHERE name_enc IS NULL`; const [notifLeft] = await sql`SELECT COUNT(*)::int as c FROM notifications WHERE title_enc IS NULL`; console.log('\n=== Verification ==='); console.log(`Users without email_enc: ${usersLeft.c}`); console.log(`Guardians without name_enc: ${guardiansLeft.c}`); console.log(`Notifications without title_enc: ${notifLeft.c}`); await sql.end(); console.log('\nDone.'); } main().catch(err => { console.error('Migration failed:', err); process.exit(1); });