auth-import.ts222 lines · main
1import { ValidationError, newId } from '@briven/shared';
2
3import { runInProjectDatabase } from '../db/data-plane.js';
4import { log } from '../lib/logger.js';
5
6/**
7 * Bulk-import users into a project's briven auth tables. Supports both
8 * CSV and JSON payloads (the route handler hands us already-parsed rows).
9 *
10 * Hash compatibility (BUILD_PLAN.md §10):
11 * - argon2id (`$argon2id$...`) — native Better Auth format, stored as-is
12 * - bcrypt (`$2a$...` / `$2b$...` / `$2y$...`) — accepted on import only;
13 * stored alongside a `hash_algo='bcrypt'` flag in `_briven_auth_accounts.scope`
14 * so the password-verify code path knows which library to dispatch to.
15 * Transparent upgrade-on-next-login lands when the verify path runs:
16 * a successful bcrypt compare triggers a re-hash with argon2id + an
17 * UPDATE on the row.
18 *
19 * Inserts are batched in a single transaction per call — partial failures
20 * roll back so the operator can fix the bad row and retry without
21 * deduplication logic.
22 */
23
24export interface ImportRow {
25 email: string;
26 name?: string | null;
27 emailVerified?: boolean;
28 /** Hash in original format. Detection is by prefix. */
29 passwordHash?: string | null;
30}
31
32export interface ImportResult {
33 inserted: number;
34 skipped: number;
35 errors: ReadonlyArray<{ row: number; message: string }>;
36}
37
38const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
39const BCRYPT_RE = /^\$2[abxy]\$/;
40const ARGON2_RE = /^\$argon2id\$/;
41
42function detectHashAlgo(hash: string): 'argon2id' | 'bcrypt' | null {
43 if (ARGON2_RE.test(hash)) return 'argon2id';
44 if (BCRYPT_RE.test(hash)) return 'bcrypt';
45 return null;
46}
47
48interface RawCountRow {
49 count: number | string;
50}
51
52/**
53 * Parse a CSV blob into rows. Minimal — quoting per RFC 4180 but no
54 * embedded newlines (operators pre-clean their exports). Header row
55 * is required and determines column mapping; column order is free.
56 */
57export function parseImportCsv(text: string): ImportRow[] {
58 const lines = text
59 .split('\n')
60 .map((l) => l.replace(/\r$/, ''))
61 .filter((l) => l.length > 0);
62 if (lines.length === 0) return [];
63 const headers = parseCsvLine(lines[0]!).map((h) => h.toLowerCase());
64 const out: ImportRow[] = [];
65 for (let i = 1; i < lines.length; i += 1) {
66 const cells = parseCsvLine(lines[i]!);
67 const row: Record<string, string> = {};
68 headers.forEach((h, idx) => {
69 row[h] = cells[idx] ?? '';
70 });
71 const email = row.email?.trim() ?? '';
72 if (!email) continue;
73 out.push({
74 email,
75 name: row.name && row.name.length > 0 ? row.name : null,
76 emailVerified: row.emailverified === 'true' || row.email_verified === 'true',
77 passwordHash:
78 row.passwordhash && row.passwordhash.length > 0 ? row.passwordhash : null,
79 });
80 }
81 return out;
82}
83
84function parseCsvLine(line: string): string[] {
85 const out: string[] = [];
86 let buf = '';
87 let quoted = false;
88 for (let i = 0; i < line.length; i += 1) {
89 const ch = line[i];
90 if (quoted) {
91 if (ch === '"' && line[i + 1] === '"') {
92 buf += '"';
93 i += 1;
94 } else if (ch === '"') {
95 quoted = false;
96 } else {
97 buf += ch ?? '';
98 }
99 } else if (ch === '"') {
100 quoted = true;
101 } else if (ch === ',') {
102 out.push(buf);
103 buf = '';
104 } else {
105 buf += ch ?? '';
106 }
107 }
108 out.push(buf);
109 return out;
110}
111
112/**
113 * Insert `rows` into the project's auth tables. Returns counts + per-row
114 * errors. Caller passes already-parsed rows (CSV via parseImportCsv,
115 * JSON via JSON.parse). Maximum 10_000 rows per call — anything bigger
116 * should split into pages.
117 */
118export async function importAuthUsers(
119 projectId: string,
120 rows: ReadonlyArray<ImportRow>,
121): Promise<ImportResult> {
122 if (rows.length === 0) {
123 return { inserted: 0, skipped: 0, errors: [] };
124 }
125 if (rows.length > 10_000) {
126 throw new ValidationError('rows per call capped at 10_000', { count: rows.length });
127 }
128
129 const errors: { row: number; message: string }[] = [];
130 let inserted = 0;
131 let skipped = 0;
132
133 await runInProjectDatabase(projectId, async (tx) => {
134 for (let i = 0; i < rows.length; i += 1) {
135 const row = rows[i]!;
136 if (!EMAIL_RE.test(row.email)) {
137 errors.push({ row: i, message: 'invalid email' });
138 continue;
139 }
140
141 let algo: 'argon2id' | 'bcrypt' | null = null;
142 if (row.passwordHash) {
143 algo = detectHashAlgo(row.passwordHash);
144 if (!algo) {
145 errors.push({ row: i, message: 'unknown password hash format' });
146 continue;
147 }
148 }
149
150 // Skip when an account with this email already exists. Idempotent
151 // re-runs of a CSV against the same tenant don't double-insert.
152 const existing = (await tx.unsafe(
153 `SELECT COUNT(*)::int AS count
154 FROM "_briven_auth_users"
155 WHERE email = $1`,
156 [row.email] as never[],
157 )) as RawCountRow[];
158 const rawCount = existing[0]?.count ?? 0;
159 // postgres.js may return BIGINT as string by default; coerce so the
160 // typecheck doesn't see `string | number > number`.
161 const count = typeof rawCount === 'string' ? Number.parseInt(rawCount, 10) : rawCount;
162 if (count > 0) {
163 skipped += 1;
164 continue;
165 }
166
167 const userId = newId('u');
168 try {
169 await tx.unsafe(
170 `INSERT INTO "_briven_auth_users" (id, email, name, email_verified)
171 VALUES ($1, $2, $3, $4)`,
172 [
173 userId,
174 row.email,
175 row.name ?? null,
176 // email_verified is a boolean (Better-Auth shape, S2.1b).
177 row.emailVerified === true,
178 ] as never[],
179 );
180
181 if (row.passwordHash && algo) {
182 // Better Auth's email/password credential lives in
183 // `_briven_auth_accounts` with provider_id='credential', the password
184 // hash in the `password` column, and account_id = the user id (Better
185 // Auth's natural key for credential accounts). The hash algorithm is
186 // recorded in `scope` so a future Better-Auth password.verify hook can
187 // verify imported argon2id/bcrypt hashes (Better Auth hashes new
188 // passwords with its own scheme — imported hashes need that hook).
189 await tx.unsafe(
190 `INSERT INTO "_briven_auth_accounts"
191 (id, user_id, account_id, provider_id, password, scope)
192 VALUES ($1, $2, $3, 'credential', $4, $5)`,
193 [
194 newId('a'),
195 userId,
196 userId,
197 row.passwordHash,
198 `hash_algo=${algo}`,
199 ] as never[],
200 );
201 }
202 inserted += 1;
203 } catch (err) {
204 errors.push({
205 row: i,
206 message: err instanceof Error ? err.message : 'insert failed',
207 });
208 }
209 }
210 });
211
212 log.info('briven_auth_import_done', {
213 projectId,
214 inserted,
215 skipped,
216 errored: errors.length,
217 });
218
219 return { inserted, skipped, errors };
220}
221
222export const __test__ = { detectHashAlgo, parseCsvLine };