diff --git a/.gitignore b/.gitignore index 7ae6cb1..641b0bb 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ codeforphilly-data/ # Agent worktrees (claude-code background agents create these here). .claude/worktrees/ +# Per-session scheduler lockfile (claude-code ScheduleWakeup). +.claude/scheduled_tasks.lock + # Editor / OS .DS_Store .vscode/* diff --git a/apps/api/package.json b/apps/api/package.json index 82471f9..f5f75cd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "script:scrub-data": "tsx scripts/scrub-data.ts", "script:setup-dev-data": "tsx scripts/setup-dev-data.ts", "script:import-laddr": "tsx scripts/import-laddr.ts", + "script:import-laddr-credentials": "tsx scripts/import-laddr-credentials.ts", "script:reconcile": "tsx scripts/reconcile.ts", "script:cutover-dry-run": "tsx scripts/cutover-dry-run.ts", "script:cutover-mailout": "tsx scripts/cutover-mailout.ts" diff --git a/apps/api/scripts/import-laddr-credentials.ts b/apps/api/scripts/import-laddr-credentials.ts new file mode 100644 index 0000000..ae56e01 --- /dev/null +++ b/apps/api/scripts/import-laddr-credentials.ts @@ -0,0 +1,363 @@ +/** + * import-laddr-credentials.ts — One-shot importer for legacy laddr + * email + password-hash records into the private store's JSONL files. + * + * The public `import-laddr.ts` script deliberately handles only public + * data; private fields (email, password hashes) are out of scope there + * because the public laddr JSON API doesn't expose them. + * + * This script consumes a CSV exported from the laddr MySQL database + * with columns `Username,Email,Password` (one row per active user), + * joins each row against the in-repo Person records by slug, and + * emits two JSONL files: + * + * profiles.jsonl — one PrivateProfile per resolved user + * legacy-passwords.jsonl — one LegacyPasswordCredential per row with + * a non-empty Password + * + * Both files are full-replace artifacts: the script always writes the + * complete set, not a diff. Re-running it after some users have + * already rehashed their credential (via login or password-reset) + * would clobber those argon2id hashes with the original SHA-1/bcrypt + * — so this is meant for the cutover seed, not mid-life maintenance. + * + * Output files are local. Deployment to the runtime backend + * (FilesystemPrivateStore PVC for sandbox / S3-compat bucket including + * GCS for prod) is a separate step — see docs/operations/cutover.md. + * + * Usage: + * npm run -w apps/api script:import-laddr-credentials -- \ + * --input .scratch/legacy-logins-export.csv \ + * --data-repo /path/to/codeforphilly-data \ + * --output-dir .scratch/private-import \ + * [--dry-run] [--verbose] + * + * Defaults: + * --input .scratch/legacy-logins-export.csv + * --data-repo $CFP_DATA_REPO_PATH (required if flag not given) + * --output-dir .scratch/private-import + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, resolve } from 'node:path'; + +import type { + LegacyPasswordCredential, + Person, + PrivateProfile, +} from '@cfp/shared/schemas'; +import { + LegacyPasswordCredentialSchema, + PrivateProfileSchema, +} from '@cfp/shared/schemas'; +import { openPublicStore } from '../src/store/public.js'; + +interface CliArgs { + readonly input: string; + readonly dataRepo: string; + readonly outputDir: string; + readonly dryRun: boolean; + readonly verbose: boolean; +} + +function parseArgs(argv: readonly string[]): CliArgs { + const opts: Record = {}; + for (const a of argv) { + if (!a.startsWith('--')) continue; + const eq = a.indexOf('='); + if (eq === -1) opts[a.slice(2)] = true; + else opts[a.slice(2, eq)] = a.slice(eq + 1); + } + + const envRepo = process.env['CFP_DATA_REPO_PATH']; + const dataRepoRaw = + typeof opts['data-repo'] === 'string' && opts['data-repo'] !== '' + ? (opts['data-repo'] as string) + : envRepo; + if (!dataRepoRaw) { + process.stderr.write( + 'missing --data-repo= (or set CFP_DATA_REPO_PATH)\n', + ); + process.exit(2); + } + + const input = + typeof opts['input'] === 'string' && opts['input'] !== '' + ? (opts['input'] as string) + : '.scratch/legacy-logins-export.csv'; + const outputDir = + typeof opts['output-dir'] === 'string' && opts['output-dir'] !== '' + ? (opts['output-dir'] as string) + : '.scratch/private-import'; + + return { + input: resolve(input), + dataRepo: resolve(dataRepoRaw), + outputDir: resolve(outputDir), + dryRun: opts['dry-run'] === true, + verbose: opts['verbose'] === true, + }; +} + +interface CsvRow { + readonly username: string; + readonly email: string; + readonly password: string; + readonly lineNumber: number; +} + +/** + * Minimal RFC-4180-ish CSV parser sufficient for our export shape. + * Handles double-quoted fields with embedded commas and "" escapes. + * Does not handle multi-line quoted fields (the laddr export has none — + * Username, Email, Password are all single-line atoms). + */ +function parseCsvLine(line: string): string[] { + const out: string[] = []; + let cur = ''; + let inQuotes = false; + for (let i = 0; i < line.length; i += 1) { + const ch = line[i]; + if (inQuotes) { + if (ch === '"') { + if (line[i + 1] === '"') { + cur += '"'; + i += 1; + } else { + inQuotes = false; + } + } else { + cur += ch; + } + } else { + if (ch === ',') { + out.push(cur); + cur = ''; + } else if (ch === '"' && cur.length === 0) { + inQuotes = true; + } else { + cur += ch; + } + } + } + out.push(cur); + return out; +} + +async function readCsv(path: string): Promise { + const raw = await readFile(path, 'utf8'); + const lines = raw.split(/\r?\n/); + if (lines.length === 0) return []; + const header = parseCsvLine(lines[0] ?? '').map((h) => h.trim()); + const idxUsername = header.indexOf('Username'); + const idxEmail = header.indexOf('Email'); + const idxPassword = header.indexOf('Password'); + if (idxUsername === -1 || idxEmail === -1 || idxPassword === -1) { + throw new Error( + `CSV header missing required columns Username/Email/Password — got: ${header.join(',')}`, + ); + } + + const rows: CsvRow[] = []; + for (let i = 1; i < lines.length; i += 1) { + const line = lines[i]; + if (line === undefined || line.length === 0) continue; + const cells = parseCsvLine(line); + rows.push({ + username: (cells[idxUsername] ?? '').trim(), + email: (cells[idxEmail] ?? '').trim(), + password: cells[idxPassword] ?? '', + lineNumber: i + 1, + }); + } + return rows; +} + +interface ImportReport { + readonly runAt: string; + readonly inputRows: number; + readonly profilesWritten: number; + readonly credentialsWritten: number; + readonly skippedNoUsername: number; + readonly skippedNoEmail: number; + readonly skippedInvalidEmail: number; + readonly skippedNoPersonMatch: number; + readonly skippedDeletedPerson: number; + readonly skippedDuplicatePersonId: number; + readonly warnings: readonly string[]; + readonly profilesPath: string | null; + readonly credentialsPath: string | null; +} + +async function run(args: CliArgs): Promise { + const runAt = new Date().toISOString(); + const warnings: string[] = []; + + console.log(`[import-creds] input=${args.input}`); + console.log(`[import-creds] data-repo=${args.dataRepo}`); + console.log(`[import-creds] output-dir=${args.outputDir}`); + console.log(`[import-creds] dry-run=${args.dryRun}`); + + if (!existsSync(args.input)) { + throw new Error(`Input file not found: ${args.input}`); + } + + const { store: publicStore } = await openPublicStore(args.dataRepo); + const people = await publicStore.people.queryAll(); + const bySlug = new Map(); + for (const p of people) bySlug.set(p.slug.toLowerCase(), p); + console.log(`[import-creds] loaded ${people.length} Person records from data repo`); + + const rows = await readCsv(args.input); + console.log(`[import-creds] parsed ${rows.length} CSV rows`); + + const profiles: PrivateProfile[] = []; + const credentials: LegacyPasswordCredential[] = []; + const seenPersonIds = new Set(); + let skippedNoUsername = 0; + let skippedNoEmail = 0; + let skippedInvalidEmail = 0; + let skippedNoPersonMatch = 0; + let skippedDeletedPerson = 0; + let skippedDuplicatePersonId = 0; + + for (const row of rows) { + if (!row.username) { + skippedNoUsername += 1; + continue; + } + if (!row.email) { + skippedNoEmail += 1; + if (args.verbose) warnings.push(`line ${row.lineNumber}: no email for username "${row.username}"`); + continue; + } + const person = bySlug.get(row.username.toLowerCase()); + if (!person) { + skippedNoPersonMatch += 1; + if (args.verbose) warnings.push(`line ${row.lineNumber}: no Person for username "${row.username}"`); + continue; + } + if (person.deletedAt) { + skippedDeletedPerson += 1; + continue; + } + if (seenPersonIds.has(person.id)) { + skippedDuplicatePersonId += 1; + warnings.push( + `line ${row.lineNumber}: duplicate username "${row.username}" → personId ${person.id}; keeping first occurrence`, + ); + continue; + } + + // Validate the email shape via the schema's parse — laddr's DB can + // hold malformed addresses (e.g. trailing whitespace already stripped + // by us, but also literal junk). Schema rejection → skip + warn. + let profile: PrivateProfile; + try { + profile = PrivateProfileSchema.parse({ + personId: person.id, + email: row.email, + emailRefreshedAt: runAt, + newsletter: null, + updatedAt: runAt, + }); + } catch (err) { + skippedInvalidEmail += 1; + if (args.verbose) { + warnings.push( + `line ${row.lineNumber}: invalid email "${row.email}" for "${row.username}" — ${(err as Error).message}`, + ); + } + continue; + } + profiles.push(profile); + seenPersonIds.add(person.id); + + // Empty password column → user has an email-only account (rare, + // some laddr users were created without a password). Emit the + // profile but no credential — they'll have to use the password-reset + // flow if they ever want one. + if (row.password.length === 0) continue; + + try { + const cred = LegacyPasswordCredentialSchema.parse({ + personId: person.id, + passwordHash: row.password, + importedAt: runAt, + lastUsedAt: null, + }); + credentials.push(cred); + } catch (err) { + warnings.push( + `line ${row.lineNumber}: invalid passwordHash for "${row.username}" — ${(err as Error).message}`, + ); + } + } + + const profilesLines = profiles.map((p) => JSON.stringify(p)).join('\n'); + const credentialsLines = credentials.map((c) => JSON.stringify(c)).join('\n'); + + let profilesPath: string | null = null; + let credentialsPath: string | null = null; + if (!args.dryRun) { + await mkdir(args.outputDir, { recursive: true }); + profilesPath = join(args.outputDir, 'profiles.jsonl'); + credentialsPath = join(args.outputDir, 'legacy-passwords.jsonl'); + await writeFile(profilesPath, profilesLines ? profilesLines + '\n' : ''); + await writeFile(credentialsPath, credentialsLines ? credentialsLines + '\n' : ''); + } + + return { + runAt, + inputRows: rows.length, + profilesWritten: profiles.length, + credentialsWritten: credentials.length, + skippedNoUsername, + skippedNoEmail, + skippedInvalidEmail, + skippedNoPersonMatch, + skippedDeletedPerson, + skippedDuplicatePersonId, + warnings, + profilesPath, + credentialsPath, + }; +} + +function printReport(report: ImportReport): void { + console.log(`\n=== import-creds report ===`); + console.log(`runAt: ${report.runAt}`); + console.log(`input rows: ${report.inputRows}`); + console.log(`profiles written: ${report.profilesWritten}`); + console.log(`credentials written: ${report.credentialsWritten}`); + console.log(`skipped (no username): ${report.skippedNoUsername}`); + console.log(`skipped (no email): ${report.skippedNoEmail}`); + console.log(`skipped (invalid email): ${report.skippedInvalidEmail}`); + console.log(`skipped (no person match): ${report.skippedNoPersonMatch}`); + console.log(`skipped (deleted person): ${report.skippedDeletedPerson}`); + console.log(`skipped (duplicate person): ${report.skippedDuplicatePersonId}`); + console.log(`warnings: ${report.warnings.length}`); + for (const w of report.warnings.slice(0, 25)) console.log(` ${w}`); + if (report.warnings.length > 25) { + console.log(` ... (${report.warnings.length - 25} more — re-run with --verbose to see all)`); + } + if (report.profilesPath) console.log(`profiles: ${report.profilesPath}`); + if (report.credentialsPath) console.log(`credentials: ${report.credentialsPath}`); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const report = await run(args); + printReport(report); +} + +const isMain = + process.argv[1] !== undefined && + import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')); + +if (isMain) { + main().catch((err: unknown) => { + console.error('[import-creds] failed:', err); + process.exit(1); + }); +} diff --git a/apps/web/src/pages/LoginPlaceholder.tsx b/apps/web/src/pages/LoginPlaceholder.tsx index 50decf4..5b82177 100644 --- a/apps/web/src/pages/LoginPlaceholder.tsx +++ b/apps/web/src/pages/LoginPlaceholder.tsx @@ -10,7 +10,6 @@ import { import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -import { Separator } from '@/components/ui/separator'; import { useAuth } from '@/hooks/useAuth'; import { api, ApiError } from '@/lib/api'; @@ -97,7 +96,7 @@ function WhyGitHub() { } export function LoginPlaceholder() { - const { person, loading } = useAuth(); + const { person, loading, reload } = useAuth(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); @@ -125,62 +124,77 @@ export function LoginPlaceholder() { ? `/api/auth/github/start?return=${encodeURIComponent(returnPath)}` : '/api/auth/github/start'; - return ( -
- - - Sign in to Code for Philly - - We use GitHub for sign-in. If you do not have a - GitHub account yet, it is free and takes about a minute. - - - - - {errorCode && ERROR_MESSAGES[errorCode] && ( -
- {ERROR_MESSAGES[errorCode]} -
- )} + const handleLegacySuccess = async () => { + // Refresh /api/auth/me so the navbar + every consumer of useAuth() + // picks up the new session before we navigate. Without this, the + // navbar stays in its anonymous state until the next hard refresh. + await reload(); + const target = + returnPath && returnPath.startsWith('/') ? returnPath : '/'; + void navigate(target, { replace: true }); + }; - + return ( +
+
+

Sign in to Code for Philly

+

+ Returning member? Use the password you had before our 2026 switch to + GitHub. New here? Sign in with GitHub. +

+
- + {errorCode && ERROR_MESSAGES[errorCode] && ( +
+ {ERROR_MESSAGES[errorCode]} +
+ )} -

- Returning Code for Philly member? If you had an - account before our 2026 switch to GitHub sign-in, you can sign in - with your old password below — or use GitHub if your old email - matches. -

+
+ + + Returning member + + Sign in with the username (or email) and password you used at + codeforphilly.org before our switch to GitHub sign-in. + + + + + + - { - const target = - returnPath && returnPath.startsWith('/') ? returnPath : '/'; - void navigate(target, { replace: true }); - }} - /> - - + + + New here? + + We use GitHub for all new sign-ups. It is free and takes about a + minute if you do not have an account yet. + + + + + + + +
); } interface LegacyPasswordLoginProps { - onSuccess: () => void; + onSuccess: () => Promise | void; } function LegacyPasswordLogin({ onSuccess }: LegacyPasswordLoginProps) { - const [open, setOpen] = useState(false); const [usernameOrEmail, setUsernameOrEmail] = useState(''); const [password, setPassword] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -193,7 +207,7 @@ function LegacyPasswordLogin({ onSuccess }: LegacyPasswordLoginProps) { setErrorMessage(null); try { await api.auth.login(usernameOrEmail.trim(), password); - onSuccess(); + await onSuccess(); } catch (err) { if (err instanceof ApiError) { if (err.status === 429) { @@ -213,19 +227,6 @@ function LegacyPasswordLogin({ onSuccess }: LegacyPasswordLoginProps) { } } - if (!open) { - return ( - - ); - } - return (
diff --git a/apps/web/tests/LoginPlaceholder.test.tsx b/apps/web/tests/LoginPlaceholder.test.tsx index 6b82d54..a118502 100644 --- a/apps/web/tests/LoginPlaceholder.test.tsx +++ b/apps/web/tests/LoginPlaceholder.test.tsx @@ -29,42 +29,25 @@ describe('LoginPlaceholder', () => { ); } - it('renders the primary GitHub button + a collapsed password disclosure', async () => { + it('renders both columns side-by-side: password form on the left + GitHub button on the right', async () => { render(); + // Password form fields are visible immediately — no disclosure to expand. await waitFor(() => { - expect(screen.getByRole('link', { name: /sign in with github/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument(); }); - // Disclosure exists but is closed — fields are not yet in the DOM - expect( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ).toBeInTheDocument(); - expect(screen.queryByLabelText(/username or email/i)).not.toBeInTheDocument(); - }); - - it('expanding the disclosure reveals the password form', async () => { - render(); - await waitFor(() => { - expect( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ).toBeInTheDocument(); - }); - fireEvent.click( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ); - expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument(); expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^sign in$/i })).toBeInTheDocument(); + // GitHub button is also visible. + expect(screen.getByRole('link', { name: /sign in with github/i })).toBeInTheDocument(); + // Forgot-password link is on the password card. + expect(screen.getByRole('link', { name: /forgot your password/i })).toBeInTheDocument(); }); it('keeps submit disabled until both fields are filled', async () => { render(); await waitFor(() => { - expect( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ).toBeInTheDocument(); + expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument(); }); - fireEvent.click( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ); const submitBtn = screen.getByRole('button', { name: /^sign in$/i }); expect(submitBtn).toBeDisabled(); @@ -100,13 +83,8 @@ describe('LoginPlaceholder', () => { render(); await waitFor(() => { - expect( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ).toBeInTheDocument(); + expect(screen.getByLabelText(/username or email/i)).toBeInTheDocument(); }); - fireEvent.click( - screen.getByRole('button', { name: /sign in with your code for philly password/i }), - ); fireEvent.change(screen.getByLabelText(/username or email/i), { target: { value: 'jane' }, }); diff --git a/docs/operations/cutover.md b/docs/operations/cutover.md index 1b1e9e7..d6bb14c 100644 --- a/docs/operations/cutover.md +++ b/docs/operations/cutover.md @@ -85,7 +85,11 @@ silently-dropped data. The production import is a snapshot commit on the `legacy-import` branch of the production data repo. Private data (emails, password hashes) is **not** -populated by this importer — see the [account-claim flow](../../specs/behaviors/account-migration.md). +populated by this importer — that is a separate one-shot run of the +credentials importer; see +[legacy-credentials-import.md](./legacy-credentials-import.md) for the +CSV format, run command, and deploy steps (sandbox PVC or production +S3-compat bucket). 1. **Bare-clone** the production data repo locally — the importer matches the running pod's invariant ([storage.md → "The data clone is bare"](../../specs/behaviors/storage.md)) diff --git a/docs/operations/legacy-credentials-import.md b/docs/operations/legacy-credentials-import.md new file mode 100644 index 0000000..66947e4 --- /dev/null +++ b/docs/operations/legacy-credentials-import.md @@ -0,0 +1,173 @@ +# Legacy credentials import + +How to seed `profiles.jsonl` (PrivateProfile) + `legacy-passwords.jsonl` +(LegacyPasswordCredential) into a deployment's private store from a +laddr MySQL export. + +The public-side importer ([`script:import-laddr`](../../apps/api/scripts/import-laddr.ts)) +deliberately handles only public data — laddr's public JSON API doesn't +expose emails or password hashes. This second importer fills the +private-side gap. + +It runs **once per environment** at cutover. After legacy users start +signing in, the in-app rehash-on-login flow gradually rotates SHA-1 / +bcrypt hashes to argon2id; re-running this importer would clobber those +rehashed credentials with the originals. The script is re-runnable +shape-wise (it always produces a complete replacement), but in practice +it's run once per env unless something went wrong with the first run. + +## What you need + +### CSV export from laddr + +A CSV with header `Username,Email,Password`, one row per active laddr +user. Each cell is double-quoted; embedded quotes use `""` escape. + +```csv +"Username","Email","Password" +"chris","chris@codeforphilly.org","$2y$10$LinW…" +"hhutch","hunter.hutchinson@gmail.com","7746451fd8c30b5b4068cd45fa7b1052cef54068" +``` + +- **Username** — laddr `User.Handle` (= the slug that became `Person.slug`). +- **Email** — required; rows with empty Email are skipped (warned). +- **Password** — the raw `PasswordHash` column from laddr's `users` table. + Mixed algorithms in the wild: older users are unsalted SHA-1 (40 lowercase + hex chars, no prefix); newer users are bcrypt (`$2a$ / $2b$ / $2y$`). + Empty Password is allowed — emits a PrivateProfile but no + LegacyPasswordCredential; user will have to use the password-reset + flow if they ever want one. The runtime verifier handles all three + formats; see [specs/behaviors/password-hash-rotation.md](../../specs/behaviors/password-hash-rotation.md). + +Produce the file with: + +```bash +mysql -h -u -p laddr \ + -B -e "SELECT Handle AS Username, Email, PasswordHash AS Password + FROM users WHERE Email IS NOT NULL AND PasswordHash IS NOT NULL" \ + | sed 's/"/\\\\"/g' | awk -F'\t' 'BEGIN{print "\"Username\",\"Email\",\"Password\""} NR>1 {printf "\"%s\",\"%s\",\"%s\"\n", $1, $2, $3}' \ + > .scratch/legacy-logins-export.csv +``` + +(Or use whatever extract tooling you prefer — the importer just needs +the CSV shape above. Land the file in `.scratch/` which is gitignored.) + +### A bare clone of `codeforphilly-data` + +The importer reads Person records (for the `slug → personId` map) via +the same `openPublicStore` interface the runtime uses, which requires a +bare clone. If your dev sibling clone is a working tree, make a +side-clone for the importer: + +```bash +git clone --bare --branch published \ + ~/Repositories/codeforphilly-data \ + /tmp/codeforphilly-data-bare-published.git +``` + +## Run + +```bash +npm run -w apps/api script:import-laddr-credentials -- \ + --input=/absolute/path/to/.scratch/legacy-logins-export.csv \ + --data-repo=/tmp/codeforphilly-data-bare-published.git \ + --output-dir=/absolute/path/to/.scratch/private-import \ + [--dry-run] [--verbose] +``` + +Defaults (when flags are omitted): + +| Flag | Default | +|----------------|-------------------------------------------| +| `--input` | `.scratch/legacy-logins-export.csv` (resolved from `apps/api/`) | +| `--data-repo` | `$CFP_DATA_REPO_PATH` (required if unset) | +| `--output-dir` | `.scratch/private-import` (resolved from `apps/api/`) | + +**Pass absolute paths** when running via npm — `npm run -w` changes +directory into the workspace and relative paths resolve from there. + +The report prints input row count, write counts, and a breakdown of +skip reasons (no-username / no-email / no-person-match / deleted-person +/ duplicate-person). Expect a ~95% match rate on a healthy laddr corpus +— the missing 5% are typically deleted laddr users that didn't survive +the public-data import. + +## Deploy to sandbox (FilesystemPrivateStore on a PVC) + +The sandbox uses `STORAGE_BACKEND=filesystem` with `/app/private-storage` +mounted to a PersistentVolumeClaim (see `deploy/kustomize/base/`). + +1. **Copy the files into the pod:** + + ```bash + POD=$(kubectl -n codeforphilly-rewrite-sandbox get pods -o jsonpath='{.items[0].metadata.name}') + kubectl -n codeforphilly-rewrite-sandbox cp \ + .scratch/private-import/profiles.jsonl "$POD:/app/private-storage/profiles.jsonl" + kubectl -n codeforphilly-rewrite-sandbox cp \ + .scratch/private-import/legacy-passwords.jsonl "$POD:/app/private-storage/legacy-passwords.jsonl" + ``` + +2. **Restart the pod** to reload the private store into memory. The + `POST /api/_internal/reload-data` webhook **does not** cover the + private store — it only reloads public + FTS. A full pod restart + is the supported path. + + ```bash + kubectl -n codeforphilly-rewrite-sandbox rollout restart deployment + ``` + +3. **Verify** the new pod sees the credentials: + + ```bash + curl -sS https://next-v2.codeforphilly.org/api/health/ready # waits until private store is loaded + ``` + +## Deploy to production (S3PrivateStore on GCS or S3) + +Production uses `STORAGE_BACKEND=s3` with `S3_ENDPOINT` pointing at +either GCS's XML API (`https://storage.googleapis.com`) or an actual +S3 bucket. Upload the two files to the bucket at the configured +`keyPrefix` (default empty): + +```bash +# GCS via gsutil +gsutil cp .scratch/private-import/profiles.jsonl \ + gs:///profiles.jsonl +gsutil cp .scratch/private-import/legacy-passwords.jsonl \ + gs:///legacy-passwords.jsonl + +# OR: any S3 endpoint via awscli +aws s3 cp .scratch/private-import/profiles.jsonl \ + s3:///profiles.jsonl --endpoint-url +aws s3 cp .scratch/private-import/legacy-passwords.jsonl \ + s3:///legacy-passwords.jsonl --endpoint-url +``` + +Then restart the prod pod the same way (private-store reload is +not in the hot-reload path; see above). + +## Safety notes + +- **The script never writes credentials to git.** Output goes to a local + directory you control. Upload to the runtime backend is a separate + manual step. +- **`.scratch/` is gitignored.** Keep the CSV and the generated JSONL + files there. Never commit either. +- **Re-running the importer overwrites the output files** locally, but + does not touch the runtime backend until you deploy the new files. +- **Re-deploying overwrites the runtime files in full.** If users have + already signed in and their credentials have been rehashed to + argon2id (via the in-app rehash-on-login flow), a re-deploy would + revert those to the SHA-1/bcrypt originals. After cutover, only + re-run + re-deploy if you've confirmed nobody has signed in (or + you're OK with the rotation reset). +- **PII risk.** The generated JSONL files contain every legacy user's + email and password hash. Treat them as you would the source MySQL + dump — never paste into chat, never check into git, delete from + local disk after deploy if you don't need them retained. + +## Cross-references + +- [specs/behaviors/private-storage.md](../../specs/behaviors/private-storage.md) — what these files store and the rules around them. +- [specs/behaviors/password-hash-rotation.md](../../specs/behaviors/password-hash-rotation.md) — how the verifier handles SHA-1 / bcrypt / argon2id and rehashes on login. +- [docs/operations/cutover.md](./cutover.md) — full cutover sequence; this importer fits between the public-data import and the DNS flip.