Skip to content

Commit b877e6e

Browse files
Merge pull request #122 from CodeForPhilly/feat/laddr-creds-import-and-login-layout
feat: legacy credentials importer + two-column /login layout
2 parents 8d4893e + bf91333 commit b877e6e

7 files changed

Lines changed: 616 additions & 93 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ codeforphilly-data/
2626
# Agent worktrees (claude-code background agents create these here).
2727
.claude/worktrees/
2828

29+
# Per-session scheduler lockfile (claude-code ScheduleWakeup).
30+
.claude/scheduled_tasks.lock
31+
2932
# Editor / OS
3033
.DS_Store
3134
.vscode/*

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"script:scrub-data": "tsx scripts/scrub-data.ts",
1414
"script:setup-dev-data": "tsx scripts/setup-dev-data.ts",
1515
"script:import-laddr": "tsx scripts/import-laddr.ts",
16+
"script:import-laddr-credentials": "tsx scripts/import-laddr-credentials.ts",
1617
"script:reconcile": "tsx scripts/reconcile.ts",
1718
"script:cutover-dry-run": "tsx scripts/cutover-dry-run.ts",
1819
"script:cutover-mailout": "tsx scripts/cutover-mailout.ts"
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
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

Comments
 (0)