migration.ts248 lines · main
1/**
2 * User import into Doltgres briven-engine.
3 * Supports plaintext passwords (hashed with engine) and pre-hashed bcrypt/argon2.
4 */
5
6import { randomBytes } from 'node:crypto';
7
8import { isAuthCoreInitialized } from './engine.js';
9import { hashPassword, signUpEmailPassword } from './emailpassword.js';
10import { getEnginePool } from './db.js';
11import { projectIdToTenantId } from './project-map.js';
12import { log } from '../../lib/logger.js';
13
14export type ImportUserInput = {
15 email?: string;
16 phoneNumber?: string;
17 /** Prefer when migrating from another system */
18 passwordHash?: string;
19 /**
20 * bcrypt | argon2 | briven-engine (scrypt-style stored by us).
21 * If omitted and passwordHash set, we guess from prefix ($2a/$2b/$2y → bcrypt, $argon2 → argon2).
22 */
23 hashingAlgorithm?: string;
24 passwordPlaintext?: string;
25 userId?: string;
26 tenantId?: string;
27 projectId?: string;
28 emailVerified?: boolean;
29 name?: string;
30};
31
32export type ImportUsersResult = {
33 engine: 'briven-engine';
34 storage: 'doltgres';
35 ok: boolean;
36 imported: number;
37 skipped: number;
38 failed: number;
39 errors: Array<{ index: number; message: string }>;
40 message?: string;
41};
42
43function newUserId(): string {
44 return `beu_${randomBytes(12).toString('hex')}`;
45}
46
47function detectAlgo(hash: string, declared?: string): string {
48 if (declared) return declared.toLowerCase();
49 if (hash.startsWith('$2a$') || hash.startsWith('$2b$') || hash.startsWith('$2y$')) {
50 return 'bcrypt';
51 }
52 if (hash.startsWith('$argon2')) return 'argon2';
53 if (hash.includes(':')) return 'briven-engine';
54 return 'unknown';
55}
56
57/**
58 * Store foreign hash as `import:algo:raw` so sign-in can verify via
59 * `verifyPasswordFlexible` (bcrypt / argon2 via Bun.password), then upgrade
60 * the row to briven scrypt on first successful login.
61 */
62function storeForeignHash(algo: string, hash: string): string {
63 return `import:${algo}:${hash}`;
64}
65
66export async function importBrivenEngineUsers(
67 users: ImportUserInput[],
68): Promise<ImportUsersResult> {
69 const base: ImportUsersResult = {
70 engine: 'briven-engine',
71 storage: 'doltgres',
72 ok: true,
73 imported: 0,
74 skipped: 0,
75 failed: 0,
76 errors: [],
77 };
78
79 if (!isAuthCoreInitialized()) {
80 return {
81 ...base,
82 ok: false,
83 message: 'briven-engine not ready on Doltgres',
84 };
85 }
86
87 const pool = getEnginePool();
88
89 for (let i = 0; i < users.length; i++) {
90 const u = users[i]!;
91 try {
92 const email = u.email?.trim().toLowerCase();
93 if (!email) {
94 base.failed++;
95 base.errors.push({ index: i, message: 'email required' });
96 continue;
97 }
98
99 const tenantId =
100 u.tenantId ??
101 (u.projectId ? projectIdToTenantId(u.projectId) : 'public');
102
103 // Ensure tenant
104 const ten = await pool.query(
105 `SELECT tenant_id FROM be_tenants WHERE tenant_id = $1 LIMIT 1`,
106 [tenantId],
107 );
108 if (!ten.rowCount) {
109 await pool.query(
110 `INSERT INTO be_tenants (tenant_id, project_id) VALUES ($1, $2)`,
111 [tenantId, u.projectId ?? tenantId],
112 );
113 }
114
115 const existing = await pool.query(
116 `SELECT id FROM be_users WHERE tenant_id = $1 AND email = $2 LIMIT 1`,
117 [tenantId, email],
118 );
119 if (existing.rowCount && existing.rowCount > 0) {
120 base.skipped++;
121 continue;
122 }
123
124 // Plaintext path — full engine signup
125 if (u.passwordPlaintext) {
126 const res = await signUpEmailPassword({
127 email,
128 password: u.passwordPlaintext,
129 tenantId,
130 projectId: u.projectId,
131 });
132 if (res.status !== 'OK') {
133 base.failed++;
134 base.errors.push({ index: i, message: res.status });
135 continue;
136 }
137 if (u.emailVerified || u.name || u.phoneNumber) {
138 await pool.query(
139 `UPDATE be_users SET
140 email_verified = COALESCE($2, email_verified),
141 phone = COALESCE($3, phone),
142 metadata_json = CASE
143 WHEN $4::text IS NULL THEN metadata_json
144 ELSE $4::text
145 END
146 WHERE id = $1`,
147 [
148 res.user.id,
149 u.emailVerified ?? null,
150 u.phoneNumber ?? null,
151 u.name ? JSON.stringify({ name: u.name }) : null,
152 ],
153 );
154 }
155 base.imported++;
156 continue;
157 }
158
159 // Hash import path
160 if (!u.passwordHash) {
161 base.failed++;
162 base.errors.push({
163 index: i,
164 message: 'passwordPlaintext or passwordHash required',
165 });
166 continue;
167 }
168
169 const algo = detectAlgo(u.passwordHash, u.hashingAlgorithm);
170 if (algo === 'unknown') {
171 base.failed++;
172 base.errors.push({
173 index: i,
174 message:
175 'unknown hash format — set hashingAlgorithm to bcrypt|argon2|briven-engine',
176 });
177 continue;
178 }
179
180 const userId = u.userId?.trim() || newUserId();
181 let storedHash: string;
182 if (algo === 'briven-engine') {
183 storedHash = u.passwordHash;
184 } else if (algo === 'bcrypt' || algo === 'argon2') {
185 storedHash = storeForeignHash(algo, u.passwordHash);
186 } else {
187 // re-hash if they sent plaintext by mistake under passwordHash — no
188 storedHash = storeForeignHash(algo, u.passwordHash);
189 }
190
191 await pool.query(
192 `INSERT INTO be_users (id, tenant_id, email, phone, email_verified, metadata_json)
193 VALUES ($1, $2, $3, $4, $5, $6)`,
194 [
195 userId,
196 tenantId,
197 email,
198 u.phoneNumber ?? null,
199 Boolean(u.emailVerified),
200 JSON.stringify(u.name ? { name: u.name } : {}),
201 ],
202 );
203 await pool.query(
204 `INSERT INTO be_password_hashes (user_id, password_hash) VALUES ($1, $2)`,
205 [userId, storedHash],
206 );
207 base.imported++;
208 } catch (err) {
209 base.failed++;
210 base.errors.push({
211 index: i,
212 message: err instanceof Error ? err.message : String(err),
213 });
214 }
215 }
216
217 log.info('briven_engine_import_users', {
218 imported: base.imported,
219 skipped: base.skipped,
220 failed: base.failed,
221 });
222
223 base.ok = base.failed === 0;
224 return base;
225}
226
227/** Convenience: import one user with plaintext (tests). */
228export async function importOnePlaintext(input: {
229 email: string;
230 password: string;
231 projectId?: string;
232}): Promise<{ ok: boolean; userId?: string; message?: string }> {
233 const r = await importBrivenEngineUsers([
234 {
235 email: input.email,
236 passwordPlaintext: input.password,
237 projectId: input.projectId,
238 },
239 ]);
240 if (r.imported === 1) return { ok: true };
241 return {
242 ok: false,
243 message: r.errors[0]?.message ?? r.message ?? 'import failed',
244 };
245}
246
247// silence unused if tree-shaken
248void hashPassword;