|
| 1 | +/** |
| 2 | + * import-laddr-credentials.ts — One-shot importer for legacy laddr |
| 3 | + * email + password-hash records into the private store's JSONL files. |
| 4 | + * |
| 5 | + * The public `import-laddr.ts` script deliberately handles only public |
| 6 | + * data; private fields (email, password hashes) are out of scope there |
| 7 | + * because the public laddr JSON API doesn't expose them. |
| 8 | + * |
| 9 | + * This script consumes a CSV exported from the laddr MySQL database |
| 10 | + * with columns `Username,Email,Password` (one row per active user), |
| 11 | + * joins each row against the in-repo Person records by slug, and |
| 12 | + * emits two JSONL files: |
| 13 | + * |
| 14 | + * profiles.jsonl — one PrivateProfile per resolved user |
| 15 | + * legacy-passwords.jsonl — one LegacyPasswordCredential per row with |
| 16 | + * a non-empty Password |
| 17 | + * |
| 18 | + * Both files are full-replace artifacts: the script always writes the |
| 19 | + * complete set, not a diff. Re-running it after some users have |
| 20 | + * already rehashed their credential (via login or password-reset) |
| 21 | + * would clobber those argon2id hashes with the original SHA-1/bcrypt |
| 22 | + * — so this is meant for the cutover seed, not mid-life maintenance. |
| 23 | + * |
| 24 | + * Output files are local. Deployment to the runtime backend |
| 25 | + * (FilesystemPrivateStore PVC for sandbox / S3-compat bucket including |
| 26 | + * GCS for prod) is a separate step — see docs/operations/cutover.md. |
| 27 | + * |
| 28 | + * Usage: |
| 29 | + * npm run -w apps/api script:import-laddr-credentials -- \ |
| 30 | + * --input .scratch/legacy-logins-export.csv \ |
| 31 | + * --data-repo /path/to/codeforphilly-data \ |
| 32 | + * --output-dir .scratch/private-import \ |
| 33 | + * [--dry-run] [--verbose] |
| 34 | + * |
| 35 | + * Defaults: |
| 36 | + * --input .scratch/legacy-logins-export.csv |
| 37 | + * --data-repo $CFP_DATA_REPO_PATH (required if flag not given) |
| 38 | + * --output-dir .scratch/private-import |
| 39 | + */ |
| 40 | +import { mkdir, readFile, writeFile } from 'node:fs/promises'; |
| 41 | +import { existsSync } from 'node:fs'; |
| 42 | +import { join, resolve } from 'node:path'; |
| 43 | + |
| 44 | +import type { |
| 45 | + LegacyPasswordCredential, |
| 46 | + Person, |
| 47 | + PrivateProfile, |
| 48 | +} from '@cfp/shared/schemas'; |
| 49 | +import { |
| 50 | + LegacyPasswordCredentialSchema, |
| 51 | + PrivateProfileSchema, |
| 52 | +} from '@cfp/shared/schemas'; |
| 53 | +import { openPublicStore } from '../src/store/public.js'; |
| 54 | + |
| 55 | +interface CliArgs { |
| 56 | + readonly input: string; |
| 57 | + readonly dataRepo: string; |
| 58 | + readonly outputDir: string; |
| 59 | + readonly dryRun: boolean; |
| 60 | + readonly verbose: boolean; |
| 61 | +} |
| 62 | + |
| 63 | +function parseArgs(argv: readonly string[]): CliArgs { |
| 64 | + const opts: Record<string, string | true> = {}; |
| 65 | + for (const a of argv) { |
| 66 | + if (!a.startsWith('--')) continue; |
| 67 | + const eq = a.indexOf('='); |
| 68 | + if (eq === -1) opts[a.slice(2)] = true; |
| 69 | + else opts[a.slice(2, eq)] = a.slice(eq + 1); |
| 70 | + } |
| 71 | + |
| 72 | + const envRepo = process.env['CFP_DATA_REPO_PATH']; |
| 73 | + const dataRepoRaw = |
| 74 | + typeof opts['data-repo'] === 'string' && opts['data-repo'] !== '' |
| 75 | + ? (opts['data-repo'] as string) |
| 76 | + : envRepo; |
| 77 | + if (!dataRepoRaw) { |
| 78 | + process.stderr.write( |
| 79 | + 'missing --data-repo=<path> (or set CFP_DATA_REPO_PATH)\n', |
| 80 | + ); |
| 81 | + process.exit(2); |
| 82 | + } |
| 83 | + |
| 84 | + const input = |
| 85 | + typeof opts['input'] === 'string' && opts['input'] !== '' |
| 86 | + ? (opts['input'] as string) |
| 87 | + : '.scratch/legacy-logins-export.csv'; |
| 88 | + const outputDir = |
| 89 | + typeof opts['output-dir'] === 'string' && opts['output-dir'] !== '' |
| 90 | + ? (opts['output-dir'] as string) |
| 91 | + : '.scratch/private-import'; |
| 92 | + |
| 93 | + return { |
| 94 | + input: resolve(input), |
| 95 | + dataRepo: resolve(dataRepoRaw), |
| 96 | + outputDir: resolve(outputDir), |
| 97 | + dryRun: opts['dry-run'] === true, |
| 98 | + verbose: opts['verbose'] === true, |
| 99 | + }; |
| 100 | +} |
| 101 | + |
| 102 | +interface CsvRow { |
| 103 | + readonly username: string; |
| 104 | + readonly email: string; |
| 105 | + readonly password: string; |
| 106 | + readonly lineNumber: number; |
| 107 | +} |
| 108 | + |
| 109 | +/** |
| 110 | + * Minimal RFC-4180-ish CSV parser sufficient for our export shape. |
| 111 | + * Handles double-quoted fields with embedded commas and "" escapes. |
| 112 | + * Does not handle multi-line quoted fields (the laddr export has none — |
| 113 | + * Username, Email, Password are all single-line atoms). |
| 114 | + */ |
| 115 | +function parseCsvLine(line: string): string[] { |
| 116 | + const out: string[] = []; |
| 117 | + let cur = ''; |
| 118 | + let inQuotes = false; |
| 119 | + for (let i = 0; i < line.length; i += 1) { |
| 120 | + const ch = line[i]; |
| 121 | + if (inQuotes) { |
| 122 | + if (ch === '"') { |
| 123 | + if (line[i + 1] === '"') { |
| 124 | + cur += '"'; |
| 125 | + i += 1; |
| 126 | + } else { |
| 127 | + inQuotes = false; |
| 128 | + } |
| 129 | + } else { |
| 130 | + cur += ch; |
| 131 | + } |
| 132 | + } else { |
| 133 | + if (ch === ',') { |
| 134 | + out.push(cur); |
| 135 | + cur = ''; |
| 136 | + } else if (ch === '"' && cur.length === 0) { |
| 137 | + inQuotes = true; |
| 138 | + } else { |
| 139 | + cur += ch; |
| 140 | + } |
| 141 | + } |
| 142 | + } |
| 143 | + out.push(cur); |
| 144 | + return out; |
| 145 | +} |
| 146 | + |
| 147 | +async function readCsv(path: string): Promise<readonly CsvRow[]> { |
| 148 | + const raw = await readFile(path, 'utf8'); |
| 149 | + const lines = raw.split(/\r?\n/); |
| 150 | + if (lines.length === 0) return []; |
| 151 | + const header = parseCsvLine(lines[0] ?? '').map((h) => h.trim()); |
| 152 | + const idxUsername = header.indexOf('Username'); |
| 153 | + const idxEmail = header.indexOf('Email'); |
| 154 | + const idxPassword = header.indexOf('Password'); |
| 155 | + if (idxUsername === -1 || idxEmail === -1 || idxPassword === -1) { |
| 156 | + throw new Error( |
| 157 | + `CSV header missing required columns Username/Email/Password — got: ${header.join(',')}`, |
| 158 | + ); |
| 159 | + } |
| 160 | + |
| 161 | + const rows: CsvRow[] = []; |
| 162 | + for (let i = 1; i < lines.length; i += 1) { |
| 163 | + const line = lines[i]; |
| 164 | + if (line === undefined || line.length === 0) continue; |
| 165 | + const cells = parseCsvLine(line); |
| 166 | + rows.push({ |
| 167 | + username: (cells[idxUsername] ?? '').trim(), |
| 168 | + email: (cells[idxEmail] ?? '').trim(), |
| 169 | + password: cells[idxPassword] ?? '', |
| 170 | + lineNumber: i + 1, |
| 171 | + }); |
| 172 | + } |
| 173 | + return rows; |
| 174 | +} |
| 175 | + |
| 176 | +interface ImportReport { |
| 177 | + readonly runAt: string; |
| 178 | + readonly inputRows: number; |
| 179 | + readonly profilesWritten: number; |
| 180 | + readonly credentialsWritten: number; |
| 181 | + readonly skippedNoUsername: number; |
| 182 | + readonly skippedNoEmail: number; |
| 183 | + readonly skippedInvalidEmail: number; |
| 184 | + readonly skippedNoPersonMatch: number; |
| 185 | + readonly skippedDeletedPerson: number; |
| 186 | + readonly skippedDuplicatePersonId: number; |
| 187 | + readonly warnings: readonly string[]; |
| 188 | + readonly profilesPath: string | null; |
| 189 | + readonly credentialsPath: string | null; |
| 190 | +} |
| 191 | + |
| 192 | +async function run(args: CliArgs): Promise<ImportReport> { |
| 193 | + const runAt = new Date().toISOString(); |
| 194 | + const warnings: string[] = []; |
| 195 | + |
| 196 | + console.log(`[import-creds] input=${args.input}`); |
| 197 | + console.log(`[import-creds] data-repo=${args.dataRepo}`); |
| 198 | + console.log(`[import-creds] output-dir=${args.outputDir}`); |
| 199 | + console.log(`[import-creds] dry-run=${args.dryRun}`); |
| 200 | + |
| 201 | + if (!existsSync(args.input)) { |
| 202 | + throw new Error(`Input file not found: ${args.input}`); |
| 203 | + } |
| 204 | + |
| 205 | + const { store: publicStore } = await openPublicStore(args.dataRepo); |
| 206 | + const people = await publicStore.people.queryAll(); |
| 207 | + const bySlug = new Map<string, Person>(); |
| 208 | + for (const p of people) bySlug.set(p.slug.toLowerCase(), p); |
| 209 | + console.log(`[import-creds] loaded ${people.length} Person records from data repo`); |
| 210 | + |
| 211 | + const rows = await readCsv(args.input); |
| 212 | + console.log(`[import-creds] parsed ${rows.length} CSV rows`); |
| 213 | + |
| 214 | + const profiles: PrivateProfile[] = []; |
| 215 | + const credentials: LegacyPasswordCredential[] = []; |
| 216 | + const seenPersonIds = new Set<string>(); |
| 217 | + let skippedNoUsername = 0; |
| 218 | + let skippedNoEmail = 0; |
| 219 | + let skippedInvalidEmail = 0; |
| 220 | + let skippedNoPersonMatch = 0; |
| 221 | + let skippedDeletedPerson = 0; |
| 222 | + let skippedDuplicatePersonId = 0; |
| 223 | + |
| 224 | + for (const row of rows) { |
| 225 | + if (!row.username) { |
| 226 | + skippedNoUsername += 1; |
| 227 | + continue; |
| 228 | + } |
| 229 | + if (!row.email) { |
| 230 | + skippedNoEmail += 1; |
| 231 | + if (args.verbose) warnings.push(`line ${row.lineNumber}: no email for username "${row.username}"`); |
| 232 | + continue; |
| 233 | + } |
| 234 | + const person = bySlug.get(row.username.toLowerCase()); |
| 235 | + if (!person) { |
| 236 | + skippedNoPersonMatch += 1; |
| 237 | + if (args.verbose) warnings.push(`line ${row.lineNumber}: no Person for username "${row.username}"`); |
| 238 | + continue; |
| 239 | + } |
| 240 | + if (person.deletedAt) { |
| 241 | + skippedDeletedPerson += 1; |
| 242 | + continue; |
| 243 | + } |
| 244 | + if (seenPersonIds.has(person.id)) { |
| 245 | + skippedDuplicatePersonId += 1; |
| 246 | + warnings.push( |
| 247 | + `line ${row.lineNumber}: duplicate username "${row.username}" → personId ${person.id}; keeping first occurrence`, |
| 248 | + ); |
| 249 | + continue; |
| 250 | + } |
| 251 | + |
| 252 | + // Validate the email shape via the schema's parse — laddr's DB can |
| 253 | + // hold malformed addresses (e.g. trailing whitespace already stripped |
| 254 | + // by us, but also literal junk). Schema rejection → skip + warn. |
| 255 | + let profile: PrivateProfile; |
| 256 | + try { |
| 257 | + profile = PrivateProfileSchema.parse({ |
| 258 | + personId: person.id, |
| 259 | + email: row.email, |
| 260 | + emailRefreshedAt: runAt, |
| 261 | + newsletter: null, |
| 262 | + updatedAt: runAt, |
| 263 | + }); |
| 264 | + } catch (err) { |
| 265 | + skippedInvalidEmail += 1; |
| 266 | + if (args.verbose) { |
| 267 | + warnings.push( |
| 268 | + `line ${row.lineNumber}: invalid email "${row.email}" for "${row.username}" — ${(err as Error).message}`, |
| 269 | + ); |
| 270 | + } |
| 271 | + continue; |
| 272 | + } |
| 273 | + profiles.push(profile); |
| 274 | + seenPersonIds.add(person.id); |
| 275 | + |
| 276 | + // Empty password column → user has an email-only account (rare, |
| 277 | + // some laddr users were created without a password). Emit the |
| 278 | + // profile but no credential — they'll have to use the password-reset |
| 279 | + // flow if they ever want one. |
| 280 | + if (row.password.length === 0) continue; |
| 281 | + |
| 282 | + try { |
| 283 | + const cred = LegacyPasswordCredentialSchema.parse({ |
| 284 | + personId: person.id, |
| 285 | + passwordHash: row.password, |
| 286 | + importedAt: runAt, |
| 287 | + lastUsedAt: null, |
| 288 | + }); |
| 289 | + credentials.push(cred); |
| 290 | + } catch (err) { |
| 291 | + warnings.push( |
| 292 | + `line ${row.lineNumber}: invalid passwordHash for "${row.username}" — ${(err as Error).message}`, |
| 293 | + ); |
| 294 | + } |
| 295 | + } |
| 296 | + |
| 297 | + const profilesLines = profiles.map((p) => JSON.stringify(p)).join('\n'); |
| 298 | + const credentialsLines = credentials.map((c) => JSON.stringify(c)).join('\n'); |
| 299 | + |
| 300 | + let profilesPath: string | null = null; |
| 301 | + let credentialsPath: string | null = null; |
| 302 | + if (!args.dryRun) { |
| 303 | + await mkdir(args.outputDir, { recursive: true }); |
| 304 | + profilesPath = join(args.outputDir, 'profiles.jsonl'); |
| 305 | + credentialsPath = join(args.outputDir, 'legacy-passwords.jsonl'); |
| 306 | + await writeFile(profilesPath, profilesLines ? profilesLines + '\n' : ''); |
| 307 | + await writeFile(credentialsPath, credentialsLines ? credentialsLines + '\n' : ''); |
| 308 | + } |
| 309 | + |
| 310 | + return { |
| 311 | + runAt, |
| 312 | + inputRows: rows.length, |
| 313 | + profilesWritten: profiles.length, |
| 314 | + credentialsWritten: credentials.length, |
| 315 | + skippedNoUsername, |
| 316 | + skippedNoEmail, |
| 317 | + skippedInvalidEmail, |
| 318 | + skippedNoPersonMatch, |
| 319 | + skippedDeletedPerson, |
| 320 | + skippedDuplicatePersonId, |
| 321 | + warnings, |
| 322 | + profilesPath, |
| 323 | + credentialsPath, |
| 324 | + }; |
| 325 | +} |
| 326 | + |
| 327 | +function printReport(report: ImportReport): void { |
| 328 | + console.log(`\n=== import-creds report ===`); |
| 329 | + console.log(`runAt: ${report.runAt}`); |
| 330 | + console.log(`input rows: ${report.inputRows}`); |
| 331 | + console.log(`profiles written: ${report.profilesWritten}`); |
| 332 | + console.log(`credentials written: ${report.credentialsWritten}`); |
| 333 | + console.log(`skipped (no username): ${report.skippedNoUsername}`); |
| 334 | + console.log(`skipped (no email): ${report.skippedNoEmail}`); |
| 335 | + console.log(`skipped (invalid email): ${report.skippedInvalidEmail}`); |
| 336 | + console.log(`skipped (no person match): ${report.skippedNoPersonMatch}`); |
| 337 | + console.log(`skipped (deleted person): ${report.skippedDeletedPerson}`); |
| 338 | + console.log(`skipped (duplicate person): ${report.skippedDuplicatePersonId}`); |
| 339 | + console.log(`warnings: ${report.warnings.length}`); |
| 340 | + for (const w of report.warnings.slice(0, 25)) console.log(` ${w}`); |
| 341 | + if (report.warnings.length > 25) { |
| 342 | + console.log(` ... (${report.warnings.length - 25} more — re-run with --verbose to see all)`); |
| 343 | + } |
| 344 | + if (report.profilesPath) console.log(`profiles: ${report.profilesPath}`); |
| 345 | + if (report.credentialsPath) console.log(`credentials: ${report.credentialsPath}`); |
| 346 | +} |
| 347 | + |
| 348 | +async function main(): Promise<void> { |
| 349 | + const args = parseArgs(process.argv.slice(2)); |
| 350 | + const report = await run(args); |
| 351 | + printReport(report); |
| 352 | +} |
| 353 | + |
| 354 | +const isMain = |
| 355 | + process.argv[1] !== undefined && |
| 356 | + import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')); |
| 357 | + |
| 358 | +if (isMain) { |
| 359 | + main().catch((err: unknown) => { |
| 360 | + console.error('[import-creds] failed:', err); |
| 361 | + process.exit(1); |
| 362 | + }); |
| 363 | +} |
0 commit comments