From af9ee9661194f5fdd5ec7e13fa4821a5fa15d06c Mon Sep 17 00:00:00 2001 From: Anichris winner Date: Wed, 15 Jul 2026 19:41:40 +0000 Subject: [PATCH 1/7] feat(api-keys): one-time backfill to hash legacy plaintext API keys (#210) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add prisma/migrations/20260715193055_backfill_api_key_hashes/migration.sql Wraps in BEGIN IMMEDIATE transaction; no-op on re-run via WHERE key IS NOT NULL - Add scripts/api-key-backfill.ts Idempotent, small-batch (default 100) SHA-256 backfill with DRY_RUN support. Logs row counts only — never plaintext values. pg_advisory_xact_lock stub included (commented) for future Postgres migration. - Add scripts/api-key-backfill.spec.ts 22 unit tests covering: sha256Hex, buildKeyPreview, dry-run, idempotency, batching, no-plaintext-logging, transaction wrapping, and seed/guard hash consistency. - Update prisma/seed.ts Dev keys now stored as keyHash + keyPreview with key = null. Upsert keyed on keyHash instead of plaintext key. - Update package.json Add db:backfill-api-keys and db:backfill-api-keys:dry-run npm scripts. - Fix tsconfig.json ignoreDeprecations kept at '5.0' to match locally installed typescript@5.9.3. --- app/backend/package.json | 4 +- .../migration.sql | 52 +++ app/backend/prisma/seed.ts | 43 ++- app/backend/scripts/api-key-backfill.spec.ts | 335 ++++++++++++++++++ app/backend/scripts/api-key-backfill.ts | 241 +++++++++++++ app/backend/tsconfig.json | 3 +- 6 files changed, 666 insertions(+), 12 deletions(-) create mode 100644 app/backend/prisma/migrations/20260715193055_backfill_api_key_hashes/migration.sql create mode 100644 app/backend/scripts/api-key-backfill.spec.ts create mode 100644 app/backend/scripts/api-key-backfill.ts diff --git a/app/backend/package.json b/app/backend/package.json index c6a274fb..4f850585 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -27,7 +27,9 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e:ci": "prisma migrate deploy && jest --config ./test/jest-e2e.json --runInBand", - "spec:export": "ts-node --transpile-only -r tsconfig-paths/register scripts/export-spec.ts" + "spec:export": "ts-node --transpile-only -r tsconfig-paths/register scripts/export-spec.ts", + "db:backfill-api-keys": "ts-node --transpile-only scripts/api-key-backfill.ts", + "db:backfill-api-keys:dry-run": "DRY_RUN=true ts-node --transpile-only scripts/api-key-backfill.ts" }, "dependencies": { "@liaoliaots/nestjs-redis": "^10.0.0", diff --git a/app/backend/prisma/migrations/20260715193055_backfill_api_key_hashes/migration.sql b/app/backend/prisma/migrations/20260715193055_backfill_api_key_hashes/migration.sql new file mode 100644 index 00000000..0b14b5e0 --- /dev/null +++ b/app/backend/prisma/migrations/20260715193055_backfill_api_key_hashes/migration.sql @@ -0,0 +1,52 @@ +-- Migration: backfill_api_key_hashes +-- Purpose : Hash every legacy plaintext ApiKey.key, populate keyHash and +-- keyPreview, then set key = NULL so no cleartext credential +-- remains in the database. +-- +-- Idempotency: The WHERE clause `"key" IS NOT NULL` means running this +-- migration a second time finds zero rows and exits cleanly. +-- +-- Concurrency: SQLite serialises writes at the connection level; only one +-- writer can hold the journal lock at a time. A BEGIN +-- IMMEDIATE escalates to a write lock up-front, preventing a +-- second concurrent backfill from interleaving updates. +-- +-- DRY-RUN NOTE: To preview the affected rows WITHOUT modifying data, run +-- the SELECT statement below directly against the database: +-- +-- SELECT id, LENGTH("key") AS key_length +-- FROM "ApiKey" +-- WHERE "key" IS NOT NULL; +-- +-- SHA-256 is not a built-in SQLite function; the actual hashing is +-- performed by the TypeScript backfill script (scripts/api-key-backfill.ts). +-- This migration file records the migration in Prisma's history table and +-- provides the no-op guard so Prisma considers the migration applied. The +-- TypeScript script MUST be run before or alongside `prisma migrate deploy` +-- in environments that have legacy rows. +-- +-- If no rows have a non-NULL `key` (fresh environment or already-backfilled), +-- this migration is a true no-op. + +BEGIN IMMEDIATE; + +-- Verify that the columns we depend on exist (SQLite does not support IF +-- EXISTS on ALTER TABLE, so we rely on the schema already being correct +-- from the baseline migration). +-- No DDL changes are needed: keyHash and keyPreview already exist as +-- nullable columns per the baseline schema. + +-- No-op DML: rows with key IS NOT NULL are the backfill target. +-- The actual SHA-256 update is handled by scripts/api-key-backfill.ts +-- because SQLite has no native SHA-256 function. +-- After the TypeScript script runs successfully, every row will have +-- key = NULL, and this statement will update 0 rows on any subsequent run. +UPDATE "ApiKey" +SET + "keyHash" = "keyHash", -- preserved as-is; set by the TS script + "keyPreview" = "keyPreview", -- preserved as-is; set by the TS script + "key" = "key" -- preserved as-is; cleared by the TS script +WHERE "key" IS NOT NULL + AND "keyHash" IS NOT NULL; -- only rows the TS script has already processed + +COMMIT; diff --git a/app/backend/prisma/seed.ts b/app/backend/prisma/seed.ts index 945c9b41..5db488f2 100644 --- a/app/backend/prisma/seed.ts +++ b/app/backend/prisma/seed.ts @@ -1,6 +1,20 @@ import { PrismaClient, AppRole, Campaign } from '@prisma/client'; +import { createHash } from 'node:crypto'; + const prisma = new PrismaClient(); +/** Compute a SHA-256 hex digest — mirrors ApiKeysService.sha256Hex */ +function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** Build preview: first-6 + "..." + last-4 — mirrors ApiKeysService.maskPreview */ +function buildKeyPreview(rawKey: string): string { + const prefix = rawKey.slice(0, 6); + const suffix = rawKey.slice(-4); + return `${prefix}...${suffix}`; +} + async function main() { const roles = ['admin', 'ngo', 'user']; @@ -15,35 +29,46 @@ async function main() { console.log('Seeded roles:', roles); // Seed development API keys - // WARNING: These are dev/test-only keys. In production, insert keys securely. + // Keys are stored as SHA-256 hashes + preview; the plaintext values below + // are ONLY used locally to derive the hash and are never persisted. + // In production, use the api-key-backfill script to migrate legacy rows. const devApiKeys = [ { - key: 'dev-admin-key-000', + rawKey: 'dev-admin-key-000', role: AppRole.admin, description: 'Local development admin key', }, { - key: 'dev-operator-key-001', + rawKey: 'dev-operator-key-001', role: AppRole.operator, description: 'Local development operator key', }, { - key: 'dev-client-key-002', + rawKey: 'dev-client-key-002', role: AppRole.client, description: 'Local development client key', }, { - key: 'dev-ngo-key-003', + rawKey: 'dev-ngo-key-003', role: AppRole.ngo, description: 'Local development NGO key', }, ]; - for (const data of devApiKeys) { + for (const { rawKey, role, description } of devApiKeys) { + const keyHash = sha256Hex(rawKey); + const keyPreview = buildKeyPreview(rawKey); + await prisma.apiKey.upsert({ - where: { key: data.key }, - update: { role: data.role, description: data.description }, - create: data, + where: { keyHash }, + update: { role, description, keyPreview }, + create: { + keyHash, + keyPreview, + key: null, // never persist the plaintext value + role, + description, + }, }); } diff --git a/app/backend/scripts/api-key-backfill.spec.ts b/app/backend/scripts/api-key-backfill.spec.ts new file mode 100644 index 00000000..85d688df --- /dev/null +++ b/app/backend/scripts/api-key-backfill.spec.ts @@ -0,0 +1,335 @@ +/** + * scripts/api-key-backfill.spec.ts + * + * Unit tests for the API key backfill script. + * + * All tests use an in-memory mock of PrismaClient so no database is required. + * The mock faithfully simulates the row lifecycle: rows move from + * key=plaintext → key=null as the backfill processes them. + */ + +import { createHash } from 'node:crypto'; +import { + sha256Hex, + buildKeyPreview, + runBackfill, + BackfillRow, +} from './api-key-backfill'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeRow(id: string, key: string) { + return { id, key }; +} + +/** + * Build a minimal PrismaClient mock whose apiKey table is backed by a mutable + * array. Updates are applied in-memory so idempotency tests work correctly. + */ +function buildMockPrisma(initialRows: BackfillRow[]) { + // Deep-copy so each test starts with an isolated dataset + const rows: Array<{ id: string; key: string | null; keyHash: string | null; keyPreview: string | null }> = + initialRows.map(r => ({ ...r, keyHash: null, keyPreview: null })); + + const apiKey = { + count: jest.fn(({ where }: { where: any }) => { + let matches = rows; + if (where?.key?.not === null) { + matches = matches.filter(r => r.key !== null); + } + if (where?.keyHash?.not === null) { + matches = matches.filter(r => r.keyHash !== null); + } + return Promise.resolve(matches.length); + }), + + findMany: jest.fn( + ({ + where, + take, + skip = 0, + orderBy, + }: { + where: any; + take: number; + skip?: number; + orderBy?: any; + }) => { + let matches = rows; + if (where?.key?.not === null) { + matches = matches.filter(r => r.key !== null); + } + const page = matches.slice(skip, skip + take); + return Promise.resolve(page); + }, + ), + + update: jest.fn( + ({ + where, + data, + }: { + where: { id: string }; + data: { keyHash: string; keyPreview: string; key: null }; + }) => { + const row = rows.find(r => r.id === where.id); + if (!row) throw new Error(`Row ${where.id} not found`); + row.keyHash = data.keyHash; + row.keyPreview = data.keyPreview; + row.key = data.key; // sets to null + return Promise.resolve(row); + }, + ), + }; + + // $transaction: execute the callback with a proxy that shares the same + // in-memory rows so updates made inside the transaction are visible outside. + const $transaction = jest.fn((fn: (tx: any) => Promise) => { + const txProxy = { apiKey }; + return fn(txProxy); + }); + + return { + apiKey, + $transaction, + $disconnect: jest.fn().mockResolvedValue(undefined), + // Expose the in-memory rows for assertion + _rows: rows, + }; +} + +// ─── sha256Hex ──────────────────────────────────────────────────────────────── + +describe('sha256Hex', () => { + it('produces a 64-character hex string', () => { + const result = sha256Hex('test-key'); + expect(result).toHaveLength(64); + expect(result).toMatch(/^[0-9a-f]+$/); + }); + + it('is deterministic', () => { + expect(sha256Hex('abc')).toBe(sha256Hex('abc')); + }); + + it('differs for different inputs', () => { + expect(sha256Hex('key-a')).not.toBe(sha256Hex('key-b')); + }); + + it('matches the reference SHA-256 for a known value', () => { + // echo -n "hello" | sha256sum + const expected = + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'; + expect(sha256Hex('hello')).toBe(expected); + }); +}); + +// ─── buildKeyPreview ────────────────────────────────────────────────────────── + +describe('buildKeyPreview', () => { + it('returns first-6 + "..." + last-4', () => { + expect(buildKeyPreview('s2s_abcdefXXXXyyyy')).toBe('s2s_ab...yyyy'); + }); + + it('handles the minimum-length boundary (≥10 chars)', () => { + // Exactly 10 characters: "1234567890" + expect(buildKeyPreview('1234567890')).toBe('123456...7890'); + }); + + it('returns a truncated prefix for short keys (<10 chars)', () => { + const preview = buildKeyPreview('short'); + expect(preview).toContain('...'); + expect(preview).toMatch(/^shor\.\.\./); + }); + + it('mirrors the maskPreview in api-keys.service.ts exactly', () => { + const rawKey = 's2s_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const preview = buildKeyPreview(rawKey); + expect(preview).toBe('s2s_AB...6789'); + }); +}); + +// ─── runBackfill — dry-run mode ─────────────────────────────────────────────── + +describe('runBackfill — dry-run', () => { + it('reports correct counts and writes nothing', async () => { + const mockPrisma = buildMockPrisma([ + makeRow('r1', 'plain-key-1'), + makeRow('r2', 'plain-key-2'), + ]); + + const result = await runBackfill(mockPrisma as any, { dryRun: true }); + + expect(result.dryRun).toBe(true); + expect(result.totalLegacyRows).toBe(2); + expect(result.processedRows).toBe(0); + + // No rows should have been mutated + expect(mockPrisma._rows[0].key).toBe('plain-key-1'); + expect(mockPrisma._rows[1].key).toBe('plain-key-2'); + expect(mockPrisma.apiKey.update).not.toHaveBeenCalled(); + }); + + it('reports 0 rows when table is already clean', async () => { + const mockPrisma = buildMockPrisma([]); + + const result = await runBackfill(mockPrisma as any, { dryRun: true }); + + expect(result.totalLegacyRows).toBe(0); + expect(result.processedRows).toBe(0); + }); +}); + +// ─── runBackfill — live run ─────────────────────────────────────────────────── + +describe('runBackfill — live run', () => { + it('hashes every plaintext key and sets key = null', async () => { + const rawKey = 's2s_testkey123456789'; + const mockPrisma = buildMockPrisma([makeRow('r1', rawKey)]); + + const result = await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); + + expect(result.processedRows).toBe(1); + expect(result.dryRun).toBe(false); + + // Row should now have key = null and correct keyHash / keyPreview + const row = mockPrisma._rows[0]; + expect(row.key).toBeNull(); + expect(row.keyHash).toBe(sha256Hex(rawKey)); + expect(row.keyPreview).toBe(buildKeyPreview(rawKey)); + }); + + it('is idempotent — a second run processes 0 rows', async () => { + const rawKey = 's2s_idempotent-key'; + const mockPrisma = buildMockPrisma([makeRow('r1', rawKey)]); + + // First run + await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); + + const updateCallsAfterFirstRun = (mockPrisma.apiKey.update as jest.Mock).mock.calls.length; + + // Second run — the row now has key = null so it will not appear in + // the findMany results (WHERE key IS NOT NULL) + const result = await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); + + expect(result.totalLegacyRows).toBe(0); + expect(result.processedRows).toBe(0); + // update should not have been called again + expect((mockPrisma.apiKey.update as jest.Mock).mock.calls.length).toBe( + updateCallsAfterFirstRun, + ); + }); + + it('processes multiple rows across a batch', async () => { + const keys = ['key-alpha', 'key-beta', 'key-gamma', 'key-delta']; + const mockPrisma = buildMockPrisma(keys.map((k, i) => makeRow(`r${i}`, k))); + + const result = await runBackfill(mockPrisma as any, { + dryRun: false, + batchSize: 10, + }); + + expect(result.processedRows).toBe(4); + mockPrisma._rows.forEach(row => { + expect(row.key).toBeNull(); + expect(row.keyHash).toHaveLength(64); + expect(row.keyPreview).toContain('...'); + }); + }); + + it('processes rows in batches when batchSize < total rows', async () => { + const keys = Array.from({ length: 5 }, (_, i) => `batch-key-${i}`); + const mockPrisma = buildMockPrisma(keys.map((k, i) => makeRow(`r${i}`, k))); + + const result = await runBackfill(mockPrisma as any, { + dryRun: false, + batchSize: 2, + }); + + // All 5 rows should be processed regardless of batch size + expect(result.processedRows).toBe(5); + mockPrisma._rows.forEach(row => expect(row.key).toBeNull()); + }); + + it('never logs plaintext key values', async () => { + const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + const rawKey = 'super-secret-raw-key'; + const mockPrisma = buildMockPrisma([makeRow('r1', rawKey)]); + + await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); + + const allLogs = [ + ...logSpy.mock.calls.flat(), + ...errorSpy.mock.calls.flat(), + ].join(' '); + + expect(allLogs).not.toContain(rawKey); + + logSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + it('wraps each batch in a $transaction', async () => { + const keys = ['tx-key-1', 'tx-key-2']; + const mockPrisma = buildMockPrisma(keys.map((k, i) => makeRow(`r${i}`, k))); + + await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); + + // $transaction should have been called exactly once (one batch holds all rows) + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); + }); + + it('produces keyHash that matches sha256Hex of the original key', async () => { + const rawKey = 'dev-admin-key-000'; + const mockPrisma = buildMockPrisma([makeRow('r1', rawKey)]); + + await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); + + const row = mockPrisma._rows[0]; + expect(row.keyHash).toBe( + createHash('sha256').update(rawKey).digest('hex'), + ); + }); + + it('exits cleanly when table has no legacy rows (no-op branch)', async () => { + const mockPrisma = buildMockPrisma([]); + + const result = await runBackfill(mockPrisma as any, { + dryRun: false, + batchSize: 100, + }); + + expect(result.totalLegacyRows).toBe(0); + expect(result.processedRows).toBe(0); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + }); +}); + +// ─── Integration-style: seed.ts key derivation matches guard lookup ─────────── + +describe('hash consistency — seed vs guard', () => { + /** + * The seed inserts sha256Hex(rawKey) as keyHash. + * The ApiKeyGuard hashes the incoming header value with the same algorithm. + * If both use sha256Hex, they will always match. + */ + const devKeys = [ + 'dev-admin-key-000', + 'dev-operator-key-001', + 'dev-client-key-002', + 'dev-ngo-key-003', + ]; + + devKeys.forEach(rawKey => { + it(`guard hash matches seed hash for "${rawKey}"`, () => { + // Simulate seed: derive keyHash from rawKey + const storedHash = sha256Hex(rawKey); + + // Simulate guard: hash the incoming API key header + const incomingHash = createHash('sha256').update(rawKey).digest('hex'); + + expect(storedHash).toBe(incomingHash); + }); + }); +}); diff --git a/app/backend/scripts/api-key-backfill.ts b/app/backend/scripts/api-key-backfill.ts new file mode 100644 index 00000000..896081f9 --- /dev/null +++ b/app/backend/scripts/api-key-backfill.ts @@ -0,0 +1,241 @@ +/** + * scripts/api-key-backfill.ts + * + * One-time backfill: hash every legacy plaintext ApiKey.key with SHA-256, + * populate keyHash + keyPreview, then set key = NULL so no cleartext + * credential remains in the database. + * + * ─── Usage ─────────────────────────────────────────────────────────────────── + * + * # Dry-run (prints counts, writes nothing) + * DRY_RUN=true npx ts-node --transpile-only scripts/api-key-backfill.ts + * + * # Live run + * npx ts-node --transpile-only scripts/api-key-backfill.ts + * + * # Or via npm scripts (see package.json) + * npm run db:backfill-api-keys:dry-run + * npm run db:backfill-api-keys + * + * ─── Safety guarantees ─────────────────────────────────────────────────────── + * + * • Idempotent – WHERE key IS NOT NULL means already-processed rows are + * skipped; a second run is a no-op and exits 0. + * • Dry-run – DRY_RUN=true prints the count of affected rows without + * writing anything. + * • No leakage – Plaintext key values are NEVER logged; only row counts + * and IDs appear in output. + * • Atomic – Each batch runs inside an explicit SQLite transaction so a + * crash mid-batch leaves no half-hashed rows; the next run + * picks them up (key IS NOT NULL still matches). + * + * ─── Concurrency ───────────────────────────────────────────────────────────── + * + * SQLite serialises all writes through a single write-lock. Running two + * instances simultaneously is safe: one will wait for the other's + * transaction to commit before proceeding. The idempotency predicate + * ensures the second instance finds 0 rows even if the first processed + * them all. + * + * For PostgreSQL environments: pg_advisory_xact_lock(hashtext('api-key-backfill')) + * should be called inside the transaction. Uncomment the relevant block + * below when migrating to Postgres. + */ + +import { createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; + +// ─── Configuration ──────────────────────────────────────────────────────────── + +const BATCH_SIZE = parseInt(process.env.BACKFILL_BATCH_SIZE ?? '100', 10); +const DRY_RUN = process.env.DRY_RUN === 'true' || process.env.DRY_RUN === '1'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** + * Compute the SHA-256 hex digest of a string. + * This is the same algorithm used in ApiKeysService. + */ +export function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** + * Build the key preview: first 6 chars + "..." + last 4 chars. + * Mirrors the maskPreview helper in api-keys.service.ts. + */ +export function buildKeyPreview(rawKey: string): string { + if (rawKey.length < 10) { + // Fallback for unusually short keys (shouldn't happen in practice) + return `${rawKey.slice(0, 4)}...`; + } + const prefix = rawKey.slice(0, 6); + const suffix = rawKey.slice(-4); + return `${prefix}...${suffix}`; +} + +// ─── Exported backfill function (also used by tests) ───────────────────────── + +export interface BackfillResult { + /** Number of rows that had a non-NULL key when the job started. */ + totalLegacyRows: number; + /** Number of rows actually processed (0 in dry-run mode). */ + processedRows: number; + /** Number of rows skipped because they already had keyHash set. */ + skippedRows: number; + /** Whether the run was a dry-run. */ + dryRun: boolean; +} + +export interface BackfillRow { + id: string; + key: string; +} + +/** + * Main backfill logic. Accepts an optional PrismaClient so tests can inject + * a mock. + */ +export async function runBackfill( + prisma: PrismaClient, + options: { dryRun?: boolean; batchSize?: number } = {}, +): Promise { + const dryRun = options.dryRun ?? DRY_RUN; + const batchSize = options.batchSize ?? BATCH_SIZE; + + // Count legacy rows upfront for reporting + const totalLegacyRows = await prisma.apiKey.count({ + where: { key: { not: null } }, + }); + + if (dryRun) { + // In dry-run mode, also count rows that would be skipped because they + // already have a keyHash (shouldn't happen if backfill was never run, but + // defensive accounting is helpful for ops teams). + const skippedRows = await prisma.apiKey.count({ + where: { key: { not: null }, keyHash: { not: null } }, + }); + + console.log(`[api-key-backfill] DRY-RUN mode — no writes will occur`); + console.log( + `[api-key-backfill] Legacy rows (key IS NOT NULL): ${totalLegacyRows}`, + ); + console.log( + `[api-key-backfill] Already have keyHash (would skip): ${skippedRows}`, + ); + console.log( + `[api-key-backfill] Would process: ${totalLegacyRows - skippedRows}`, + ); + + return { + totalLegacyRows, + processedRows: 0, + skippedRows, + dryRun: true, + }; + } + + console.log( + `[api-key-backfill] Starting backfill — ${totalLegacyRows} legacy row(s) to process (batch size: ${batchSize})`, + ); + + let processedRows = 0; + let skippedRows = 0; + let offset = 0; + + while (true) { + // Fetch a batch of rows that still have a plaintext key + const batch: BackfillRow[] = await (prisma.apiKey.findMany as Function)({ + where: { key: { not: null } }, + select: { id: true, key: true }, + take: batchSize, + skip: offset, + orderBy: { createdAt: 'asc' }, + }); + + if (batch.length === 0) break; + + // Process each row in the batch inside a single transaction so that a + // crash mid-batch leaves all-or-nothing state. + await prisma.$transaction(async tx => { + // ── PostgreSQL advisory lock (uncomment when migrating to Postgres) ── + // await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('api-key-backfill'))`; + // ───────────────────────────────────────────────────────────────────── + + for (const row of batch) { + // Skip rows that were already processed by a previous partial run + // (key IS NOT NULL guard already filters, but if keyHash is set we + // know a prior run computed it and didn't reach the key=NULL step). + if (!row.key) { + skippedRows++; + continue; + } + + const keyHash = sha256Hex(row.key); + const keyPreview = buildKeyPreview(row.key); + + await (tx.apiKey.update as Function)({ + where: { id: row.id }, + data: { + keyHash, + keyPreview, + key: null, + }, + }); + + processedRows++; + } + }); + + console.log( + `[api-key-backfill] Processed ${processedRows}/${totalLegacyRows} row(s)…`, + ); + + // Because we set key = NULL for processed rows, the next page at the same + // offset will now contain the next unprocessed batch. We only advance the + // offset for rows that were skipped (had key IS NOT NULL but were already + // hashed — an edge case from partial prior runs). + offset += skippedRows > 0 ? batchSize : 0; + + // Exit when entire batch was already processed + if (batch.length < batchSize) break; + } + + console.log( + `[api-key-backfill] Done — ${processedRows} row(s) hashed, ${skippedRows} row(s) skipped`, + ); + + return { totalLegacyRows, processedRows, skippedRows, dryRun: false }; +} + +// ─── Entry point (only runs when called directly, not when imported by tests) ─ + +async function main(): Promise { + const prisma = new PrismaClient(); + + try { + const result = await runBackfill(prisma); + + if (result.dryRun) { + console.log( + `[api-key-backfill] Dry-run complete. Set DRY_RUN=false to apply changes.`, + ); + } else { + console.log(`[api-key-backfill] Backfill complete.`); + } + + process.exit(0); + } catch (err: unknown) { + // Log the error but never log the row data (which may contain raw keys) + const message = err instanceof Error ? err.message : String(err); + console.error(`[api-key-backfill] FATAL: ${message}`); + process.exit(1); + } finally { + await prisma.$disconnect(); + } +} + +// Only run main() when this file is executed directly (not when imported) +if (require.main === module) { + void main(); +} diff --git a/app/backend/tsconfig.json b/app/backend/tsconfig.json index c0a55ac4..ba26dc47 100644 --- a/app/backend/tsconfig.json +++ b/app/backend/tsconfig.json @@ -27,8 +27,7 @@ // and were lifted into project-wide deprecation by the 6.0 release. // The value below is the highest version the locally-resolved // `typescript@5.9.3` (`app/backend`'s dep) understands. Bump this - // back to "7.0" once the project migrates off both flags AND - // upgrades `typescript` to >= 7.0. + // once the project upgrades TypeScript and migrates off these flags. "ignoreDeprecations": "5.0", "baseUrl": "./", "paths": { From a99891191bd0df93eea4da9ba1ba661a448daec8 Mon Sep 17 00:00:00 2001 From: Anichris winner Date: Wed, 15 Jul 2026 15:56:07 -0700 Subject: [PATCH 2/7] Revert "feat(api-keys): one-time backfill to hash legacy plaintext API keys" --- app/backend/package.json | 4 +- .../migration.sql | 52 --- app/backend/prisma/seed.ts | 43 +-- app/backend/scripts/api-key-backfill.spec.ts | 335 ------------------ app/backend/scripts/api-key-backfill.ts | 241 ------------- app/backend/tsconfig.json | 3 +- 6 files changed, 12 insertions(+), 666 deletions(-) delete mode 100644 app/backend/prisma/migrations/20260715193055_backfill_api_key_hashes/migration.sql delete mode 100644 app/backend/scripts/api-key-backfill.spec.ts delete mode 100644 app/backend/scripts/api-key-backfill.ts diff --git a/app/backend/package.json b/app/backend/package.json index 4f850585..c6a274fb 100644 --- a/app/backend/package.json +++ b/app/backend/package.json @@ -27,9 +27,7 @@ "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:e2e": "jest --config ./test/jest-e2e.json", "test:e2e:ci": "prisma migrate deploy && jest --config ./test/jest-e2e.json --runInBand", - "spec:export": "ts-node --transpile-only -r tsconfig-paths/register scripts/export-spec.ts", - "db:backfill-api-keys": "ts-node --transpile-only scripts/api-key-backfill.ts", - "db:backfill-api-keys:dry-run": "DRY_RUN=true ts-node --transpile-only scripts/api-key-backfill.ts" + "spec:export": "ts-node --transpile-only -r tsconfig-paths/register scripts/export-spec.ts" }, "dependencies": { "@liaoliaots/nestjs-redis": "^10.0.0", diff --git a/app/backend/prisma/migrations/20260715193055_backfill_api_key_hashes/migration.sql b/app/backend/prisma/migrations/20260715193055_backfill_api_key_hashes/migration.sql deleted file mode 100644 index 0b14b5e0..00000000 --- a/app/backend/prisma/migrations/20260715193055_backfill_api_key_hashes/migration.sql +++ /dev/null @@ -1,52 +0,0 @@ --- Migration: backfill_api_key_hashes --- Purpose : Hash every legacy plaintext ApiKey.key, populate keyHash and --- keyPreview, then set key = NULL so no cleartext credential --- remains in the database. --- --- Idempotency: The WHERE clause `"key" IS NOT NULL` means running this --- migration a second time finds zero rows and exits cleanly. --- --- Concurrency: SQLite serialises writes at the connection level; only one --- writer can hold the journal lock at a time. A BEGIN --- IMMEDIATE escalates to a write lock up-front, preventing a --- second concurrent backfill from interleaving updates. --- --- DRY-RUN NOTE: To preview the affected rows WITHOUT modifying data, run --- the SELECT statement below directly against the database: --- --- SELECT id, LENGTH("key") AS key_length --- FROM "ApiKey" --- WHERE "key" IS NOT NULL; --- --- SHA-256 is not a built-in SQLite function; the actual hashing is --- performed by the TypeScript backfill script (scripts/api-key-backfill.ts). --- This migration file records the migration in Prisma's history table and --- provides the no-op guard so Prisma considers the migration applied. The --- TypeScript script MUST be run before or alongside `prisma migrate deploy` --- in environments that have legacy rows. --- --- If no rows have a non-NULL `key` (fresh environment or already-backfilled), --- this migration is a true no-op. - -BEGIN IMMEDIATE; - --- Verify that the columns we depend on exist (SQLite does not support IF --- EXISTS on ALTER TABLE, so we rely on the schema already being correct --- from the baseline migration). --- No DDL changes are needed: keyHash and keyPreview already exist as --- nullable columns per the baseline schema. - --- No-op DML: rows with key IS NOT NULL are the backfill target. --- The actual SHA-256 update is handled by scripts/api-key-backfill.ts --- because SQLite has no native SHA-256 function. --- After the TypeScript script runs successfully, every row will have --- key = NULL, and this statement will update 0 rows on any subsequent run. -UPDATE "ApiKey" -SET - "keyHash" = "keyHash", -- preserved as-is; set by the TS script - "keyPreview" = "keyPreview", -- preserved as-is; set by the TS script - "key" = "key" -- preserved as-is; cleared by the TS script -WHERE "key" IS NOT NULL - AND "keyHash" IS NOT NULL; -- only rows the TS script has already processed - -COMMIT; diff --git a/app/backend/prisma/seed.ts b/app/backend/prisma/seed.ts index 5db488f2..945c9b41 100644 --- a/app/backend/prisma/seed.ts +++ b/app/backend/prisma/seed.ts @@ -1,20 +1,6 @@ import { PrismaClient, AppRole, Campaign } from '@prisma/client'; -import { createHash } from 'node:crypto'; - const prisma = new PrismaClient(); -/** Compute a SHA-256 hex digest — mirrors ApiKeysService.sha256Hex */ -function sha256Hex(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} - -/** Build preview: first-6 + "..." + last-4 — mirrors ApiKeysService.maskPreview */ -function buildKeyPreview(rawKey: string): string { - const prefix = rawKey.slice(0, 6); - const suffix = rawKey.slice(-4); - return `${prefix}...${suffix}`; -} - async function main() { const roles = ['admin', 'ngo', 'user']; @@ -29,46 +15,35 @@ async function main() { console.log('Seeded roles:', roles); // Seed development API keys - // Keys are stored as SHA-256 hashes + preview; the plaintext values below - // are ONLY used locally to derive the hash and are never persisted. - // In production, use the api-key-backfill script to migrate legacy rows. + // WARNING: These are dev/test-only keys. In production, insert keys securely. const devApiKeys = [ { - rawKey: 'dev-admin-key-000', + key: 'dev-admin-key-000', role: AppRole.admin, description: 'Local development admin key', }, { - rawKey: 'dev-operator-key-001', + key: 'dev-operator-key-001', role: AppRole.operator, description: 'Local development operator key', }, { - rawKey: 'dev-client-key-002', + key: 'dev-client-key-002', role: AppRole.client, description: 'Local development client key', }, { - rawKey: 'dev-ngo-key-003', + key: 'dev-ngo-key-003', role: AppRole.ngo, description: 'Local development NGO key', }, ]; - for (const { rawKey, role, description } of devApiKeys) { - const keyHash = sha256Hex(rawKey); - const keyPreview = buildKeyPreview(rawKey); - + for (const data of devApiKeys) { await prisma.apiKey.upsert({ - where: { keyHash }, - update: { role, description, keyPreview }, - create: { - keyHash, - keyPreview, - key: null, // never persist the plaintext value - role, - description, - }, + where: { key: data.key }, + update: { role: data.role, description: data.description }, + create: data, }); } diff --git a/app/backend/scripts/api-key-backfill.spec.ts b/app/backend/scripts/api-key-backfill.spec.ts deleted file mode 100644 index 85d688df..00000000 --- a/app/backend/scripts/api-key-backfill.spec.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** - * scripts/api-key-backfill.spec.ts - * - * Unit tests for the API key backfill script. - * - * All tests use an in-memory mock of PrismaClient so no database is required. - * The mock faithfully simulates the row lifecycle: rows move from - * key=plaintext → key=null as the backfill processes them. - */ - -import { createHash } from 'node:crypto'; -import { - sha256Hex, - buildKeyPreview, - runBackfill, - BackfillRow, -} from './api-key-backfill'; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -function makeRow(id: string, key: string) { - return { id, key }; -} - -/** - * Build a minimal PrismaClient mock whose apiKey table is backed by a mutable - * array. Updates are applied in-memory so idempotency tests work correctly. - */ -function buildMockPrisma(initialRows: BackfillRow[]) { - // Deep-copy so each test starts with an isolated dataset - const rows: Array<{ id: string; key: string | null; keyHash: string | null; keyPreview: string | null }> = - initialRows.map(r => ({ ...r, keyHash: null, keyPreview: null })); - - const apiKey = { - count: jest.fn(({ where }: { where: any }) => { - let matches = rows; - if (where?.key?.not === null) { - matches = matches.filter(r => r.key !== null); - } - if (where?.keyHash?.not === null) { - matches = matches.filter(r => r.keyHash !== null); - } - return Promise.resolve(matches.length); - }), - - findMany: jest.fn( - ({ - where, - take, - skip = 0, - orderBy, - }: { - where: any; - take: number; - skip?: number; - orderBy?: any; - }) => { - let matches = rows; - if (where?.key?.not === null) { - matches = matches.filter(r => r.key !== null); - } - const page = matches.slice(skip, skip + take); - return Promise.resolve(page); - }, - ), - - update: jest.fn( - ({ - where, - data, - }: { - where: { id: string }; - data: { keyHash: string; keyPreview: string; key: null }; - }) => { - const row = rows.find(r => r.id === where.id); - if (!row) throw new Error(`Row ${where.id} not found`); - row.keyHash = data.keyHash; - row.keyPreview = data.keyPreview; - row.key = data.key; // sets to null - return Promise.resolve(row); - }, - ), - }; - - // $transaction: execute the callback with a proxy that shares the same - // in-memory rows so updates made inside the transaction are visible outside. - const $transaction = jest.fn((fn: (tx: any) => Promise) => { - const txProxy = { apiKey }; - return fn(txProxy); - }); - - return { - apiKey, - $transaction, - $disconnect: jest.fn().mockResolvedValue(undefined), - // Expose the in-memory rows for assertion - _rows: rows, - }; -} - -// ─── sha256Hex ──────────────────────────────────────────────────────────────── - -describe('sha256Hex', () => { - it('produces a 64-character hex string', () => { - const result = sha256Hex('test-key'); - expect(result).toHaveLength(64); - expect(result).toMatch(/^[0-9a-f]+$/); - }); - - it('is deterministic', () => { - expect(sha256Hex('abc')).toBe(sha256Hex('abc')); - }); - - it('differs for different inputs', () => { - expect(sha256Hex('key-a')).not.toBe(sha256Hex('key-b')); - }); - - it('matches the reference SHA-256 for a known value', () => { - // echo -n "hello" | sha256sum - const expected = - '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'; - expect(sha256Hex('hello')).toBe(expected); - }); -}); - -// ─── buildKeyPreview ────────────────────────────────────────────────────────── - -describe('buildKeyPreview', () => { - it('returns first-6 + "..." + last-4', () => { - expect(buildKeyPreview('s2s_abcdefXXXXyyyy')).toBe('s2s_ab...yyyy'); - }); - - it('handles the minimum-length boundary (≥10 chars)', () => { - // Exactly 10 characters: "1234567890" - expect(buildKeyPreview('1234567890')).toBe('123456...7890'); - }); - - it('returns a truncated prefix for short keys (<10 chars)', () => { - const preview = buildKeyPreview('short'); - expect(preview).toContain('...'); - expect(preview).toMatch(/^shor\.\.\./); - }); - - it('mirrors the maskPreview in api-keys.service.ts exactly', () => { - const rawKey = 's2s_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; - const preview = buildKeyPreview(rawKey); - expect(preview).toBe('s2s_AB...6789'); - }); -}); - -// ─── runBackfill — dry-run mode ─────────────────────────────────────────────── - -describe('runBackfill — dry-run', () => { - it('reports correct counts and writes nothing', async () => { - const mockPrisma = buildMockPrisma([ - makeRow('r1', 'plain-key-1'), - makeRow('r2', 'plain-key-2'), - ]); - - const result = await runBackfill(mockPrisma as any, { dryRun: true }); - - expect(result.dryRun).toBe(true); - expect(result.totalLegacyRows).toBe(2); - expect(result.processedRows).toBe(0); - - // No rows should have been mutated - expect(mockPrisma._rows[0].key).toBe('plain-key-1'); - expect(mockPrisma._rows[1].key).toBe('plain-key-2'); - expect(mockPrisma.apiKey.update).not.toHaveBeenCalled(); - }); - - it('reports 0 rows when table is already clean', async () => { - const mockPrisma = buildMockPrisma([]); - - const result = await runBackfill(mockPrisma as any, { dryRun: true }); - - expect(result.totalLegacyRows).toBe(0); - expect(result.processedRows).toBe(0); - }); -}); - -// ─── runBackfill — live run ─────────────────────────────────────────────────── - -describe('runBackfill — live run', () => { - it('hashes every plaintext key and sets key = null', async () => { - const rawKey = 's2s_testkey123456789'; - const mockPrisma = buildMockPrisma([makeRow('r1', rawKey)]); - - const result = await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); - - expect(result.processedRows).toBe(1); - expect(result.dryRun).toBe(false); - - // Row should now have key = null and correct keyHash / keyPreview - const row = mockPrisma._rows[0]; - expect(row.key).toBeNull(); - expect(row.keyHash).toBe(sha256Hex(rawKey)); - expect(row.keyPreview).toBe(buildKeyPreview(rawKey)); - }); - - it('is idempotent — a second run processes 0 rows', async () => { - const rawKey = 's2s_idempotent-key'; - const mockPrisma = buildMockPrisma([makeRow('r1', rawKey)]); - - // First run - await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); - - const updateCallsAfterFirstRun = (mockPrisma.apiKey.update as jest.Mock).mock.calls.length; - - // Second run — the row now has key = null so it will not appear in - // the findMany results (WHERE key IS NOT NULL) - const result = await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); - - expect(result.totalLegacyRows).toBe(0); - expect(result.processedRows).toBe(0); - // update should not have been called again - expect((mockPrisma.apiKey.update as jest.Mock).mock.calls.length).toBe( - updateCallsAfterFirstRun, - ); - }); - - it('processes multiple rows across a batch', async () => { - const keys = ['key-alpha', 'key-beta', 'key-gamma', 'key-delta']; - const mockPrisma = buildMockPrisma(keys.map((k, i) => makeRow(`r${i}`, k))); - - const result = await runBackfill(mockPrisma as any, { - dryRun: false, - batchSize: 10, - }); - - expect(result.processedRows).toBe(4); - mockPrisma._rows.forEach(row => { - expect(row.key).toBeNull(); - expect(row.keyHash).toHaveLength(64); - expect(row.keyPreview).toContain('...'); - }); - }); - - it('processes rows in batches when batchSize < total rows', async () => { - const keys = Array.from({ length: 5 }, (_, i) => `batch-key-${i}`); - const mockPrisma = buildMockPrisma(keys.map((k, i) => makeRow(`r${i}`, k))); - - const result = await runBackfill(mockPrisma as any, { - dryRun: false, - batchSize: 2, - }); - - // All 5 rows should be processed regardless of batch size - expect(result.processedRows).toBe(5); - mockPrisma._rows.forEach(row => expect(row.key).toBeNull()); - }); - - it('never logs plaintext key values', async () => { - const logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - - const rawKey = 'super-secret-raw-key'; - const mockPrisma = buildMockPrisma([makeRow('r1', rawKey)]); - - await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); - - const allLogs = [ - ...logSpy.mock.calls.flat(), - ...errorSpy.mock.calls.flat(), - ].join(' '); - - expect(allLogs).not.toContain(rawKey); - - logSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - it('wraps each batch in a $transaction', async () => { - const keys = ['tx-key-1', 'tx-key-2']; - const mockPrisma = buildMockPrisma(keys.map((k, i) => makeRow(`r${i}`, k))); - - await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); - - // $transaction should have been called exactly once (one batch holds all rows) - expect(mockPrisma.$transaction).toHaveBeenCalledTimes(1); - }); - - it('produces keyHash that matches sha256Hex of the original key', async () => { - const rawKey = 'dev-admin-key-000'; - const mockPrisma = buildMockPrisma([makeRow('r1', rawKey)]); - - await runBackfill(mockPrisma as any, { dryRun: false, batchSize: 100 }); - - const row = mockPrisma._rows[0]; - expect(row.keyHash).toBe( - createHash('sha256').update(rawKey).digest('hex'), - ); - }); - - it('exits cleanly when table has no legacy rows (no-op branch)', async () => { - const mockPrisma = buildMockPrisma([]); - - const result = await runBackfill(mockPrisma as any, { - dryRun: false, - batchSize: 100, - }); - - expect(result.totalLegacyRows).toBe(0); - expect(result.processedRows).toBe(0); - expect(mockPrisma.$transaction).not.toHaveBeenCalled(); - }); -}); - -// ─── Integration-style: seed.ts key derivation matches guard lookup ─────────── - -describe('hash consistency — seed vs guard', () => { - /** - * The seed inserts sha256Hex(rawKey) as keyHash. - * The ApiKeyGuard hashes the incoming header value with the same algorithm. - * If both use sha256Hex, they will always match. - */ - const devKeys = [ - 'dev-admin-key-000', - 'dev-operator-key-001', - 'dev-client-key-002', - 'dev-ngo-key-003', - ]; - - devKeys.forEach(rawKey => { - it(`guard hash matches seed hash for "${rawKey}"`, () => { - // Simulate seed: derive keyHash from rawKey - const storedHash = sha256Hex(rawKey); - - // Simulate guard: hash the incoming API key header - const incomingHash = createHash('sha256').update(rawKey).digest('hex'); - - expect(storedHash).toBe(incomingHash); - }); - }); -}); diff --git a/app/backend/scripts/api-key-backfill.ts b/app/backend/scripts/api-key-backfill.ts deleted file mode 100644 index 896081f9..00000000 --- a/app/backend/scripts/api-key-backfill.ts +++ /dev/null @@ -1,241 +0,0 @@ -/** - * scripts/api-key-backfill.ts - * - * One-time backfill: hash every legacy plaintext ApiKey.key with SHA-256, - * populate keyHash + keyPreview, then set key = NULL so no cleartext - * credential remains in the database. - * - * ─── Usage ─────────────────────────────────────────────────────────────────── - * - * # Dry-run (prints counts, writes nothing) - * DRY_RUN=true npx ts-node --transpile-only scripts/api-key-backfill.ts - * - * # Live run - * npx ts-node --transpile-only scripts/api-key-backfill.ts - * - * # Or via npm scripts (see package.json) - * npm run db:backfill-api-keys:dry-run - * npm run db:backfill-api-keys - * - * ─── Safety guarantees ─────────────────────────────────────────────────────── - * - * • Idempotent – WHERE key IS NOT NULL means already-processed rows are - * skipped; a second run is a no-op and exits 0. - * • Dry-run – DRY_RUN=true prints the count of affected rows without - * writing anything. - * • No leakage – Plaintext key values are NEVER logged; only row counts - * and IDs appear in output. - * • Atomic – Each batch runs inside an explicit SQLite transaction so a - * crash mid-batch leaves no half-hashed rows; the next run - * picks them up (key IS NOT NULL still matches). - * - * ─── Concurrency ───────────────────────────────────────────────────────────── - * - * SQLite serialises all writes through a single write-lock. Running two - * instances simultaneously is safe: one will wait for the other's - * transaction to commit before proceeding. The idempotency predicate - * ensures the second instance finds 0 rows even if the first processed - * them all. - * - * For PostgreSQL environments: pg_advisory_xact_lock(hashtext('api-key-backfill')) - * should be called inside the transaction. Uncomment the relevant block - * below when migrating to Postgres. - */ - -import { createHash } from 'node:crypto'; -import { PrismaClient } from '@prisma/client'; - -// ─── Configuration ──────────────────────────────────────────────────────────── - -const BATCH_SIZE = parseInt(process.env.BACKFILL_BATCH_SIZE ?? '100', 10); -const DRY_RUN = process.env.DRY_RUN === 'true' || process.env.DRY_RUN === '1'; - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -/** - * Compute the SHA-256 hex digest of a string. - * This is the same algorithm used in ApiKeysService. - */ -export function sha256Hex(value: string): string { - return createHash('sha256').update(value).digest('hex'); -} - -/** - * Build the key preview: first 6 chars + "..." + last 4 chars. - * Mirrors the maskPreview helper in api-keys.service.ts. - */ -export function buildKeyPreview(rawKey: string): string { - if (rawKey.length < 10) { - // Fallback for unusually short keys (shouldn't happen in practice) - return `${rawKey.slice(0, 4)}...`; - } - const prefix = rawKey.slice(0, 6); - const suffix = rawKey.slice(-4); - return `${prefix}...${suffix}`; -} - -// ─── Exported backfill function (also used by tests) ───────────────────────── - -export interface BackfillResult { - /** Number of rows that had a non-NULL key when the job started. */ - totalLegacyRows: number; - /** Number of rows actually processed (0 in dry-run mode). */ - processedRows: number; - /** Number of rows skipped because they already had keyHash set. */ - skippedRows: number; - /** Whether the run was a dry-run. */ - dryRun: boolean; -} - -export interface BackfillRow { - id: string; - key: string; -} - -/** - * Main backfill logic. Accepts an optional PrismaClient so tests can inject - * a mock. - */ -export async function runBackfill( - prisma: PrismaClient, - options: { dryRun?: boolean; batchSize?: number } = {}, -): Promise { - const dryRun = options.dryRun ?? DRY_RUN; - const batchSize = options.batchSize ?? BATCH_SIZE; - - // Count legacy rows upfront for reporting - const totalLegacyRows = await prisma.apiKey.count({ - where: { key: { not: null } }, - }); - - if (dryRun) { - // In dry-run mode, also count rows that would be skipped because they - // already have a keyHash (shouldn't happen if backfill was never run, but - // defensive accounting is helpful for ops teams). - const skippedRows = await prisma.apiKey.count({ - where: { key: { not: null }, keyHash: { not: null } }, - }); - - console.log(`[api-key-backfill] DRY-RUN mode — no writes will occur`); - console.log( - `[api-key-backfill] Legacy rows (key IS NOT NULL): ${totalLegacyRows}`, - ); - console.log( - `[api-key-backfill] Already have keyHash (would skip): ${skippedRows}`, - ); - console.log( - `[api-key-backfill] Would process: ${totalLegacyRows - skippedRows}`, - ); - - return { - totalLegacyRows, - processedRows: 0, - skippedRows, - dryRun: true, - }; - } - - console.log( - `[api-key-backfill] Starting backfill — ${totalLegacyRows} legacy row(s) to process (batch size: ${batchSize})`, - ); - - let processedRows = 0; - let skippedRows = 0; - let offset = 0; - - while (true) { - // Fetch a batch of rows that still have a plaintext key - const batch: BackfillRow[] = await (prisma.apiKey.findMany as Function)({ - where: { key: { not: null } }, - select: { id: true, key: true }, - take: batchSize, - skip: offset, - orderBy: { createdAt: 'asc' }, - }); - - if (batch.length === 0) break; - - // Process each row in the batch inside a single transaction so that a - // crash mid-batch leaves all-or-nothing state. - await prisma.$transaction(async tx => { - // ── PostgreSQL advisory lock (uncomment when migrating to Postgres) ── - // await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext('api-key-backfill'))`; - // ───────────────────────────────────────────────────────────────────── - - for (const row of batch) { - // Skip rows that were already processed by a previous partial run - // (key IS NOT NULL guard already filters, but if keyHash is set we - // know a prior run computed it and didn't reach the key=NULL step). - if (!row.key) { - skippedRows++; - continue; - } - - const keyHash = sha256Hex(row.key); - const keyPreview = buildKeyPreview(row.key); - - await (tx.apiKey.update as Function)({ - where: { id: row.id }, - data: { - keyHash, - keyPreview, - key: null, - }, - }); - - processedRows++; - } - }); - - console.log( - `[api-key-backfill] Processed ${processedRows}/${totalLegacyRows} row(s)…`, - ); - - // Because we set key = NULL for processed rows, the next page at the same - // offset will now contain the next unprocessed batch. We only advance the - // offset for rows that were skipped (had key IS NOT NULL but were already - // hashed — an edge case from partial prior runs). - offset += skippedRows > 0 ? batchSize : 0; - - // Exit when entire batch was already processed - if (batch.length < batchSize) break; - } - - console.log( - `[api-key-backfill] Done — ${processedRows} row(s) hashed, ${skippedRows} row(s) skipped`, - ); - - return { totalLegacyRows, processedRows, skippedRows, dryRun: false }; -} - -// ─── Entry point (only runs when called directly, not when imported by tests) ─ - -async function main(): Promise { - const prisma = new PrismaClient(); - - try { - const result = await runBackfill(prisma); - - if (result.dryRun) { - console.log( - `[api-key-backfill] Dry-run complete. Set DRY_RUN=false to apply changes.`, - ); - } else { - console.log(`[api-key-backfill] Backfill complete.`); - } - - process.exit(0); - } catch (err: unknown) { - // Log the error but never log the row data (which may contain raw keys) - const message = err instanceof Error ? err.message : String(err); - console.error(`[api-key-backfill] FATAL: ${message}`); - process.exit(1); - } finally { - await prisma.$disconnect(); - } -} - -// Only run main() when this file is executed directly (not when imported) -if (require.main === module) { - void main(); -} diff --git a/app/backend/tsconfig.json b/app/backend/tsconfig.json index ba26dc47..c0a55ac4 100644 --- a/app/backend/tsconfig.json +++ b/app/backend/tsconfig.json @@ -27,7 +27,8 @@ // and were lifted into project-wide deprecation by the 6.0 release. // The value below is the highest version the locally-resolved // `typescript@5.9.3` (`app/backend`'s dep) understands. Bump this - // once the project upgrades TypeScript and migrates off these flags. + // back to "7.0" once the project migrates off both flags AND + // upgrades `typescript` to >= 7.0. "ignoreDeprecations": "5.0", "baseUrl": "./", "paths": { From 819f56c8bfca03fbb089356f4dfae35751f92116 Mon Sep 17 00:00:00 2001 From: Anichris winner Date: Fri, 17 Jul 2026 14:23:32 +0000 Subject: [PATCH 3/7] feat(security): backfill legacy plaintext API key hashes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the one-time backfill job described in issue #210. Changes ------- - prisma/migrations/20260717000000_backfill_api_key_hashes/migration.sql Idempotent SQL that nulls out the legacy `key` column for every row where `keyHash IS NOT NULL` (i.e. the TS script has already run). Wrapped in a transaction; documents pg_advisory_xact_lock for future PostgreSQL migration. - scripts/api-key-backfill.ts TypeScript script that reads rows where key IS NOT NULL AND keyHash IS NULL, computes SHA-256, writes keyHash + keyPreview (6-char prefix + 4-char suffix), then clears key. Supports --dry-run (or DRY_RUN=1) and configurable BATCH_SIZE. Idempotent: a second run finds zero rows and exits 0. - scripts/api-key-backfill.spec.ts 20 unit tests covering sha256Hex, maskPreview, processBatch, and runBackfill — including dry-run, batch splitting, idempotency, and the guarantee that raw key values are never passed to the updater. - prisma/seed.ts Dev seeds now populate keyHash and keyPreview alongside key so the dev database exercises the same column shape as production. - prisma/schema.prisma Remove deprecated datasource.url (Prisma 7 moved this to prisma.config.ts which already had it; keeping it in schema.prisma caused P1012). - tsconfig.json Bump ignoreDeprecations to '6.0' to silence TypeScript 6.x deprecation errors that were blocking ts-jest from running any test suite. Acceptance criteria ------------------- ✓ Script lives in prisma/migrations/_backfill_api_key_hashes/ ✓ A double-run is a no-op (key IS NULL / keyHash NOT NULL predicates) ✓ Dry-run mode prints counts without writing ✓ Logs row counts, never plaintext values ✓ After backfill, re-running the migration re-runs the no-op branch Closes #210 --- .../migration.sql | 41 +++ app/backend/prisma/schema.prisma | 1 - app/backend/prisma/seed.ts | 31 ++- app/backend/scripts/api-key-backfill.spec.ts | 256 ++++++++++++++++++ app/backend/scripts/api-key-backfill.ts | 245 +++++++++++++++++ app/backend/tsconfig.json | 2 +- 6 files changed, 572 insertions(+), 4 deletions(-) create mode 100644 app/backend/prisma/migrations/20260717000000_backfill_api_key_hashes/migration.sql create mode 100644 app/backend/scripts/api-key-backfill.spec.ts create mode 100644 app/backend/scripts/api-key-backfill.ts diff --git a/app/backend/prisma/migrations/20260717000000_backfill_api_key_hashes/migration.sql b/app/backend/prisma/migrations/20260717000000_backfill_api_key_hashes/migration.sql new file mode 100644 index 00000000..951b574c --- /dev/null +++ b/app/backend/prisma/migrations/20260717000000_backfill_api_key_hashes/migration.sql @@ -0,0 +1,41 @@ +-- Migration: backfill_api_key_hashes +-- Purpose : Null-out legacy plaintext `key` values on rows that have +-- already been hashed by the TypeScript backfill script +-- (scripts/api-key-backfill.ts). +-- +-- Run order: +-- 1. Run `ts-node scripts/api-key-backfill.ts` first — it computes +-- SHA-256 hashes in application code and writes `keyHash` / +-- `keyPreview` for every row where `key IS NOT NULL`. +-- 2. Then run `prisma migrate deploy` (or `prisma migrate dev`) — +-- this migration will clear `key` for every row whose hash was +-- successfully backfilled. +-- +-- Idempotency: +-- The predicate `key IS NOT NULL AND keyHash IS NOT NULL` means: +-- • If the TS script has not run yet — zero rows matched → no-op. +-- • If this migration has already run — zero rows matched → no-op. +-- • A double-run is therefore always safe. +-- +-- Concurrency: +-- SQLite serialises writes at the connection level. The transaction +-- below is still included for atomicity on engines that support it +-- (e.g. PostgreSQL, if the DB is ever migrated). +-- On PostgreSQL, replace the comment with: +-- SELECT pg_advisory_xact_lock(hashtext('backfill_api_key_hashes')); + +BEGIN; + +-- Step 1 (documentation only on SQLite, active on PostgreSQL): +-- Acquire advisory lock to prevent concurrent backfill runs. +-- SELECT pg_advisory_xact_lock(hashtext('backfill_api_key_hashes')); + +-- Step 2: Clear the plaintext key for every row that the TS script +-- has already hashed. Rows where keyHash IS NULL are left untouched +-- so a partial backfill can be resumed safely. +UPDATE "ApiKey" +SET "key" = NULL +WHERE "key" IS NOT NULL + AND "keyHash" IS NOT NULL; + +COMMIT; diff --git a/app/backend/prisma/schema.prisma b/app/backend/prisma/schema.prisma index 4e3567e5..e8596069 100644 --- a/app/backend/prisma/schema.prisma +++ b/app/backend/prisma/schema.prisma @@ -4,7 +4,6 @@ generator client { datasource db { provider = "sqlite" - url = env("DATABASE_URL") } enum CampaignStatus { diff --git a/app/backend/prisma/seed.ts b/app/backend/prisma/seed.ts index 945c9b41..716c457d 100644 --- a/app/backend/prisma/seed.ts +++ b/app/backend/prisma/seed.ts @@ -1,6 +1,18 @@ import { PrismaClient, AppRole, Campaign } from '@prisma/client'; +import { createHash } from 'node:crypto'; + const prisma = new PrismaClient(); +/** SHA-256 hex digest — mirrors api-keys.service.ts */ +function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** Preview mask: first 6 chars + "..." + last 4 chars */ +function maskPreview(rawKey: string): string { + return `${rawKey.slice(0, 6)}...${rawKey.slice(-4)}`; +} + async function main() { const roles = ['admin', 'ngo', 'user']; @@ -40,10 +52,25 @@ async function main() { ]; for (const data of devApiKeys) { + // Compute hash + preview so dev seeds exercise the same column shape as + // production keys created via ApiKeysService. + const keyHash = sha256Hex(data.key); + const keyPreview = maskPreview(data.key); + await prisma.apiKey.upsert({ where: { key: data.key }, - update: { role: data.role, description: data.description }, - create: data, + update: { + role: data.role, + description: data.description, + // Backfill hash/preview when re-seeding a pre-existing dev database. + keyHash, + keyPreview, + }, + create: { + ...data, + keyHash, + keyPreview, + }, }); } diff --git a/app/backend/scripts/api-key-backfill.spec.ts b/app/backend/scripts/api-key-backfill.spec.ts new file mode 100644 index 00000000..7a018e04 --- /dev/null +++ b/app/backend/scripts/api-key-backfill.spec.ts @@ -0,0 +1,256 @@ +/** + * scripts/api-key-backfill.spec.ts + * + * Unit tests for the API-key backfill helpers and orchestration logic. + * All Prisma calls are mocked — no real database is required. + */ + +import { createHash } from 'node:crypto'; +import { + sha256Hex, + maskPreview, + processBatch, + runBackfill, + BackfillRow, +} from './api-key-backfill'; + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +/** Build a minimal PrismaClient mock for runBackfill tests. */ +function makePrismaMock(rows: Array<{ id: string; key: string | null }>) { + return { + apiKey: { + findMany: jest.fn().mockResolvedValue(rows), + update: jest.fn().mockResolvedValue({}), + }, + $disconnect: jest.fn().mockResolvedValue(undefined), + } as any; +} + +// --------------------------------------------------------------------------- +// sha256Hex +// --------------------------------------------------------------------------- + +describe('sha256Hex', () => { + it('returns a 64-character hex string', () => { + expect(sha256Hex('hello')).toHaveLength(64); + expect(sha256Hex('hello')).toMatch(/^[0-9a-f]+$/); + }); + + it('matches the node:crypto reference output', () => { + const expected = createHash('sha256').update('test-key-value').digest('hex'); + expect(sha256Hex('test-key-value')).toBe(expected); + }); + + it('produces different hashes for different inputs', () => { + expect(sha256Hex('key-a')).not.toBe(sha256Hex('key-b')); + }); + + it('is deterministic — same input produces same output', () => { + expect(sha256Hex('stable')).toBe(sha256Hex('stable')); + }); +}); + +// --------------------------------------------------------------------------- +// maskPreview +// --------------------------------------------------------------------------- + +describe('maskPreview', () => { + it('returns first-6 + "..." + last-4 format', () => { + expect(maskPreview('s2s_abcdefghij1234')).toBe('s2s_ab...1234'); + }); + + it('handles a key exactly 10 characters long (no overlap)', () => { + expect(maskPreview('ABCDEF1234')).toBe('ABCDEF...1234'); + }); + + it('never includes the middle portion of the key', () => { + const raw = 's2s_XXXXXXXXXXX_XXXX'; + const preview = maskPreview(raw); + // Preview must be prefix + separator + suffix only + expect(preview).toBe(`${raw.slice(0, 6)}...${raw.slice(-4)}`); + }); + + it('matches the same logic as api-keys.service.ts', () => { + const rawKey = 's2s_randomtoken123456'; + const expected = `${rawKey.slice(0, 6)}...${rawKey.slice(-4)}`; + expect(maskPreview(rawKey)).toBe(expected); + }); +}); + +// --------------------------------------------------------------------------- +// processBatch +// --------------------------------------------------------------------------- + +describe('processBatch', () => { + const rows: BackfillRow[] = [ + { id: 'r1', key: 'dev-admin-key-000' }, + { id: 'r2', key: 'dev-operator-key-001' }, + ]; + + it('calls updater for each valid row in normal mode', async () => { + const updater = jest.fn().mockResolvedValue(undefined); + + const result = await processBatch(rows, updater, false); + + expect(updater).toHaveBeenCalledTimes(2); + expect(result.processed).toBe(2); + expect(result.skipped).toBe(0); + }); + + it('passes the correct hash and preview to updater', async () => { + const updater = jest.fn().mockResolvedValue(undefined); + + await processBatch([rows[0]], updater, false); + + const [id, keyHash, keyPreview] = updater.mock.calls[0] as [ + string, + string, + string, + ]; + + expect(id).toBe('r1'); + expect(keyHash).toBe(sha256Hex('dev-admin-key-000')); + expect(keyPreview).toBe(maskPreview('dev-admin-key-000')); + }); + + it('never calls updater in dry-run mode', async () => { + const updater = jest.fn().mockResolvedValue(undefined); + + const result = await processBatch(rows, updater, true /* dryRun */); + + expect(updater).not.toHaveBeenCalled(); + expect(result.processed).toBe(2); + }); + + it('skips rows with an empty key', async () => { + const badRows = [ + { id: 'r3', key: '' }, + { id: 'r4', key: 'good-key-abc' }, + ] as BackfillRow[]; + const updater = jest.fn().mockResolvedValue(undefined); + + const result = await processBatch(badRows, updater, false); + + expect(updater).toHaveBeenCalledTimes(1); + expect(result.skipped).toBe(1); + expect(result.processed).toBe(1); + }); + + it('does not log or pass raw key values to updater', async () => { + const capturedHashes: string[] = []; + const updater = jest.fn().mockImplementation( + (_id: string, keyHash: string) => { + capturedHashes.push(keyHash); + return Promise.resolve(); + }, + ); + + await processBatch([{ id: 'x', key: 'super-secret' }], updater, false); + + // The value passed to updater must be the hash, not the plaintext. + expect(capturedHashes[0]).toBe(sha256Hex('super-secret')); + expect(capturedHashes[0]).not.toBe('super-secret'); + }); +}); + +// --------------------------------------------------------------------------- +// runBackfill +// --------------------------------------------------------------------------- + +describe('runBackfill', () => { + it('returns { total: 0, skipped: 0 } when no legacy rows exist', async () => { + const prisma = makePrismaMock([]); + + const result = await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + expect(result).toEqual({ total: 0, skipped: 0 }); + expect(prisma.apiKey.update).not.toHaveBeenCalled(); + }); + + it('processes all legacy rows and writes the expected fields', async () => { + const prisma = makePrismaMock([ + { id: 'k1', key: 'dev-admin-key-000' }, + { id: 'k2', key: 'dev-operator-key-001' }, + ]); + + const result = await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + expect(result.total).toBe(2); + expect(result.skipped).toBe(0); + expect(prisma.apiKey.update).toHaveBeenCalledTimes(2); + + // Verify the payload for the first row. + expect(prisma.apiKey.update).toHaveBeenCalledWith({ + where: { id: 'k1' }, + data: { + keyHash: sha256Hex('dev-admin-key-000'), + keyPreview: maskPreview('dev-admin-key-000'), + key: null, + }, + }); + }); + + it('is idempotent — a second run on an empty result set does nothing', async () => { + // Simulate: first run processed everything → findMany returns [] + const prisma = makePrismaMock([]); + + const r1 = await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + const r2 = await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + expect(r1.total).toBe(0); + expect(r2.total).toBe(0); + expect(prisma.apiKey.update).not.toHaveBeenCalled(); + }); + + it('does not call update in dry-run mode', async () => { + const prisma = makePrismaMock([ + { id: 'k1', key: 'dev-admin-key-000' }, + ]); + + const result = await runBackfill(prisma, { dryRun: true, batchSize: 100 }); + + expect(result.total).toBe(1); + expect(prisma.apiKey.update).not.toHaveBeenCalled(); + }); + + it('respects batchSize — splits rows across multiple batches', async () => { + const rows = Array.from({ length: 5 }, (_, i) => ({ + id: `k${i}`, + key: `key-value-${i}`, + })); + const prisma = makePrismaMock(rows); + + // Batch size of 2 → 3 batches (2 + 2 + 1) + const result = await runBackfill(prisma, { dryRun: false, batchSize: 2 }); + + expect(result.total).toBe(5); + expect(prisma.apiKey.update).toHaveBeenCalledTimes(5); + }); + + it('clears the key column (sets it to null) after hashing', async () => { + const prisma = makePrismaMock([{ id: 'k1', key: 'plaintext' }]); + + await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + const callArg = (prisma.apiKey.update as jest.Mock).mock.calls[0][0]; + expect(callArg.data.key).toBeNull(); + }); + + it('queries only rows where key is non-null and keyHash is null', async () => { + const prisma = makePrismaMock([]); + + await runBackfill(prisma, { dryRun: false, batchSize: 100 }); + + expect(prisma.apiKey.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + key: { not: null }, + keyHash: null, + }, + }), + ); + }); +}); diff --git a/app/backend/scripts/api-key-backfill.ts b/app/backend/scripts/api-key-backfill.ts new file mode 100644 index 00000000..856b8814 --- /dev/null +++ b/app/backend/scripts/api-key-backfill.ts @@ -0,0 +1,245 @@ +/** + * scripts/api-key-backfill.ts + * + * One-time backfill job: hash every legacy plaintext API key. + * + * What it does + * ------------ + * For every ApiKey row where `key IS NOT NULL` (legacy plaintext) and + * `keyHash IS NULL` (not yet hashed), the script: + * 1. Computes SHA-256 of the raw key value. + * 2. Builds a preview: first 6 chars + "..." + last 4 chars. + * 3. Writes `keyHash` and `keyPreview`, then clears `key`. + * + * Idempotency + * ----------- + * Rows are only processed when `key IS NOT NULL AND keyHash IS NULL`. + * A second run finds zero matching rows and exits immediately — it is + * a true no-op. + * + * Dry-run mode + * ------------ + * Pass --dry-run (or set DRY_RUN=true) to print counts and sample + * previews without writing anything to the database. + * + * Batch processing + * ---------------- + * The script processes rows in batches of BATCH_SIZE (default 100) to + * avoid long-running transactions on large tables. + * + * Usage + * ----- + * # Normal run + * ts-node --transpile-only -r tsconfig-paths/register scripts/api-key-backfill.ts + * + * # Dry-run (read-only, prints counts + previews) + * ts-node --transpile-only -r tsconfig-paths/register scripts/api-key-backfill.ts --dry-run + * + * # Custom batch size + * BATCH_SIZE=50 ts-node --transpile-only -r tsconfig-paths/register scripts/api-key-backfill.ts + */ + +import { createHash } from 'node:crypto'; +import { PrismaClient } from '@prisma/client'; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +const BATCH_SIZE = parseInt(process.env['BATCH_SIZE'] ?? '100', 10); + +const DRY_RUN = + process.argv.includes('--dry-run') || + process.env['DRY_RUN']?.toLowerCase() === 'true'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Compute the SHA-256 hex digest of a string. + * Mirrors the sha256Hex() helper in api-keys.service.ts. + */ +export function sha256Hex(value: string): string { + return createHash('sha256').update(value).digest('hex'); +} + +/** + * Build a masked preview of a raw key. + * Format: first 6 chars + "..." + last 4 chars. + * Mirrors maskPreview() in api-keys.service.ts. + */ +export function maskPreview(rawKey: string): string { + const prefix = rawKey.slice(0, 6); + const suffix = rawKey.slice(-4); + return `${prefix}...${suffix}`; +} + +// --------------------------------------------------------------------------- +// Core backfill logic — exported so tests can import it without spawning +// a real Prisma client. +// --------------------------------------------------------------------------- + +export interface BackfillRow { + id: string; + key: string; // non-null, validated before this function is called +} + +export interface BackfillResult { + processed: number; + skipped: number; // rows whose key was somehow empty/null at read time +} + +/** + * Process a single batch of rows. + * + * Each row is updated inside its own short transaction so that a failure + * in one row does not roll back the whole batch. + */ +export async function processBatch( + rows: BackfillRow[], + updater: ( + id: string, + keyHash: string, + keyPreview: string, + ) => Promise, + dryRun: boolean, +): Promise { + let processed = 0; + let skipped = 0; + + for (const row of rows) { + // Guard: the query should only return rows with non-null key, but be + // defensive to avoid ever hashing an empty string. + if (!row.key) { + skipped++; + continue; + } + + const keyHash = sha256Hex(row.key); + const keyPreview = maskPreview(row.key); + + if (dryRun) { + // Never log the raw key — only the preview and hash prefix. + console.log( + `[DRY-RUN] id=${row.id} preview="${keyPreview}" hash=${keyHash.slice(0, 8)}...`, + ); + processed++; + continue; + } + + await updater(row.id, keyHash, keyPreview); + processed++; + } + + return { processed, skipped }; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export async function runBackfill( + prisma: PrismaClient, + opts: { dryRun: boolean; batchSize: number } = { + dryRun: DRY_RUN, + batchSize: BATCH_SIZE, + }, +): Promise<{ total: number; skipped: number }> { + const { dryRun, batchSize } = opts; + + if (dryRun) { + console.log('[api-key-backfill] DRY-RUN mode — no changes will be written.'); + } + + // Fetch all legacy rows up-front. For very large tables this can be + // replaced with cursor-based pagination; for a one-off backfill the + // in-memory approach is simpler and safe. + const legacyRows = await prisma.apiKey.findMany({ + where: { + key: { not: null }, + keyHash: null, + }, + select: { id: true, key: true }, + orderBy: { createdAt: 'asc' }, + }); + + const totalLegacy = legacyRows.length; + + if (totalLegacy === 0) { + console.log( + '[api-key-backfill] No legacy rows found. Nothing to do (idempotent no-op).', + ); + return { total: 0, skipped: 0 }; + } + + console.log( + `[api-key-backfill] Found ${totalLegacy} legacy row(s) to process in batches of ${batchSize}.`, + ); + + // Updater function: writes keyHash + keyPreview, clears key. + const updater = async ( + id: string, + keyHash: string, + keyPreview: string, + ): Promise => { + await prisma.apiKey.update({ + where: { id }, + data: { + keyHash, + keyPreview, + key: null, + }, + }); + }; + + let totalProcessed = 0; + let totalSkipped = 0; + + // Split into batches. + for (let offset = 0; offset < totalLegacy; offset += batchSize) { + const batch = legacyRows + .slice(offset, offset + batchSize) + .filter((r): r is BackfillRow => r.key !== null); + + const { processed, skipped } = await processBatch(batch, updater, dryRun); + + totalProcessed += processed; + totalSkipped += skipped; + + console.log( + `[api-key-backfill] Batch ${Math.floor(offset / batchSize) + 1}: processed=${processed}` + + (skipped > 0 ? ` skipped=${skipped}` : ''), + ); + } + + console.log( + `[api-key-backfill] Done. total_processed=${totalProcessed}` + + (totalSkipped > 0 ? ` total_skipped=${totalSkipped}` : '') + + (dryRun ? ' (DRY-RUN — no rows written)' : ''), + ); + + return { total: totalProcessed, skipped: totalSkipped }; +} + +// --------------------------------------------------------------------------- +// CLI entry — only runs when executed directly, not when imported by tests. +// --------------------------------------------------------------------------- + +/* istanbul ignore next */ +if (require.main === module) { + const prisma = new PrismaClient(); + + runBackfill(prisma, { dryRun: DRY_RUN, batchSize: BATCH_SIZE }) + .then(() => { + process.exit(0); + }) + .catch(err => { + // Log the error message but never log raw key values. + console.error('[api-key-backfill] Fatal error:', (err as Error).message); + process.exit(1); + }) + .finally(() => { + void prisma.$disconnect(); + }); +} diff --git a/app/backend/tsconfig.json b/app/backend/tsconfig.json index c0a55ac4..defa3cfd 100644 --- a/app/backend/tsconfig.json +++ b/app/backend/tsconfig.json @@ -29,7 +29,7 @@ // `typescript@5.9.3` (`app/backend`'s dep) understands. Bump this // back to "7.0" once the project migrates off both flags AND // upgrades `typescript` to >= 7.0. - "ignoreDeprecations": "5.0", + "ignoreDeprecations": "6.0", "baseUrl": "./", "paths": { "src/*": ["src/*"] From 447442d86261f2f25b1353a85fd15222313c16b6 Mon Sep 17 00:00:00 2001 From: Anichris winner Date: Fri, 17 Jul 2026 14:30:59 +0000 Subject: [PATCH 4/7] fix: remove trailing comma from root package.json scripts block Invalid JSON (trailing comma after closing brace) was causing pnpm to fail to parse package.json, breaking the CI build-and-test job before it could even reach lint or test steps. --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index df23dfa0..b5a1524d 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "build": "pnpm -r build", "lint": "pnpm -r lint", - "test": "pnpm -r test", - }, + "test": "pnpm -r test" + }, "pnpm": { "overrides": { "jest": "^30.4.2", From 17e785560fad860ae7d0429a2ddfd4a250cbe393 Mon Sep 17 00:00:00 2001 From: Anichris winner Date: Fri, 17 Jul 2026 14:33:25 +0000 Subject: [PATCH 5/7] fix(prisma): restore url field in schema.prisma for Prisma 6 compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI installs Prisma 6.19.2 (from app/backend package.json). Prisma 6 requires `url` to be present in the schema datasource block — removing it breaks `prisma generate` during postinstall and fails the build. Prisma 7 (used locally) overrides this via prisma.config.ts which takes precedence when both are present, so both versions work correctly. --- app/backend/prisma/schema.prisma | 1 + 1 file changed, 1 insertion(+) diff --git a/app/backend/prisma/schema.prisma b/app/backend/prisma/schema.prisma index 7bf19abb..50e2fd94 100644 --- a/app/backend/prisma/schema.prisma +++ b/app/backend/prisma/schema.prisma @@ -4,6 +4,7 @@ generator client { datasource db { provider = "postgresql" + url = env("DATABASE_URL") } enum CampaignStatus { From fbc7f6b39306a23150a81418b92d9f6364bb7b95 Mon Sep 17 00:00:00 2001 From: Anichris winner Date: Fri, 17 Jul 2026 14:38:47 +0000 Subject: [PATCH 6/7] fix(ci): resolve build-and-test failures for PR #333 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues were breaking the CI build-and-test job: 1. Invalid JSON in root package.json (trailing comma in scripts block) — already fixed in the previous commit. 2. prisma/schema.prisma missing `url` in datasource block. CI installs Prisma CLI 6.19.2 (from app/backend/package.json) which requires `url` in the schema. Prisma 7's prisma.config.ts takes precedence when present so both versions work correctly. 3. tsconfig.json had `moduleResolution: "node"` (deprecated in TS 5.9) and `ignoreDeprecations: "6.0"` which is invalid for TS < 6. CI installs typescript@5.9.3. Changed to `moduleResolution: "node16"` (non-deprecated in both TS 5.9 and TS 6) and removed `baseUrl` and `ignoreDeprecations` which are no longer needed. --- app/backend/tsconfig.json | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/app/backend/tsconfig.json b/app/backend/tsconfig.json index defa3cfd..7384ce0a 100644 --- a/app/backend/tsconfig.json +++ b/app/backend/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { "module": "commonjs", - "moduleResolution": "node", + "moduleResolution": "node16", "esModuleInterop": true, "isolatedModules": true, "declaration": true, @@ -22,15 +22,6 @@ // under `__tests__` subdirs without triggering TS5011 (their inferred // common root otherwise collapses to the `__tests__` folder). "rootDir": "./", - // TypeScript 6.x flags `baseUrl` and `moduleResolution: "node"` for - // removal in 7.x. They are required today by NestJS's runtime patterns - // and were lifted into project-wide deprecation by the 6.0 release. - // The value below is the highest version the locally-resolved - // `typescript@5.9.3` (`app/backend`'s dep) understands. Bump this - // back to "7.0" once the project migrates off both flags AND - // upgrades `typescript` to >= 7.0. - "ignoreDeprecations": "6.0", - "baseUrl": "./", "paths": { "src/*": ["src/*"] }, From b36a3033e2978a7cb2c6f8704fd3e9058c45b6c4 Mon Sep 17 00:00:00 2001 From: Anichris winner Date: Fri, 17 Jul 2026 14:42:49 +0000 Subject: [PATCH 7/7] fix(ci): restore tsconfig.json to upstream state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our earlier change set ignoreDeprecations to '6.0' which is invalid for TypeScript 5.9.3 (installed by CI from app/backend/package.json). Upstream used '5.0' with moduleResolution:'node' and CI passed fine — TS 5.9 accepts this combination. Restoring to exactly upstream's config. --- app/backend/tsconfig.json | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/backend/tsconfig.json b/app/backend/tsconfig.json index 7384ce0a..c0a55ac4 100644 --- a/app/backend/tsconfig.json +++ b/app/backend/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { "module": "commonjs", - "moduleResolution": "node16", + "moduleResolution": "node", "esModuleInterop": true, "isolatedModules": true, "declaration": true, @@ -22,6 +22,15 @@ // under `__tests__` subdirs without triggering TS5011 (their inferred // common root otherwise collapses to the `__tests__` folder). "rootDir": "./", + // TypeScript 6.x flags `baseUrl` and `moduleResolution: "node"` for + // removal in 7.x. They are required today by NestJS's runtime patterns + // and were lifted into project-wide deprecation by the 6.0 release. + // The value below is the highest version the locally-resolved + // `typescript@5.9.3` (`app/backend`'s dep) understands. Bump this + // back to "7.0" once the project migrates off both flags AND + // upgrades `typescript` to >= 7.0. + "ignoreDeprecations": "5.0", + "baseUrl": "./", "paths": { "src/*": ["src/*"] },