diff --git a/frontend/scripts/import-supabase-auth-users.mjs b/frontend/scripts/import-supabase-auth-users.mjs new file mode 100644 index 00000000..06afbcc9 --- /dev/null +++ b/frontend/scripts/import-supabase-auth-users.mjs @@ -0,0 +1,195 @@ +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Pool } from "pg"; + +class SafeImportError extends Error {} + +function requiredDate(value, field) { + const date = new Date(value); + if (!value || !Number.isFinite(date.getTime())) { + throw new SafeImportError(`source contains an invalid ${field}`); + } + return date; +} + +function optionalDate(value, field) { + if (value === null || value === undefined || value === "") return null; + return requiredDate(value, field); +} + +function metadataValue(metadata, key) { + if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) { + return null; + } + const value = metadata[key]; + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +export function normalizeSupabaseUsers(source) { + if (!Array.isArray(source)) { + throw new SafeImportError("source must be a JSON array of auth users"); + } + + const emails = new Set(); + return source.map((record) => { + if (!record || typeof record !== "object" || Array.isArray(record)) { + throw new SafeImportError("source contains an invalid auth user"); + } + + const id = typeof record.id === "string" ? record.id.trim().toLowerCase() : ""; + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id)) { + throw new SafeImportError("source contains an invalid user id"); + } + const email = + typeof record.email === "string" ? record.email.trim().toLowerCase() : ""; + if (!/^[^\s@]+@[^\s@]+$/.test(email)) { + throw new SafeImportError("source contains an invalid user email"); + } + if (emails.has(email)) { + throw new SafeImportError("source contains duplicate canonical emails"); + } + emails.add(email); + + const emailVerifiedAt = optionalDate( + record.email_confirmed_at, + "email confirmation timestamp", + ); + const metadata = record.raw_user_meta_data; + const name = + metadataValue(metadata, "full_name") ?? + metadataValue(metadata, "name") ?? + email.slice(0, email.indexOf("@")); + + return { + id, + email, + emailVerified: emailVerifiedAt !== null, + emailVerifiedAt, + name, + image: + metadataValue(metadata, "avatar_url") ?? + metadataValue(metadata, "picture"), + createdAt: requiredDate(record.created_at, "creation timestamp"), + updatedAt: requiredDate(record.updated_at, "update timestamp"), + }; + }); +} + +export async function applyIdentityUsers(client, users) { + await client.query("BEGIN"); + try { + for (const user of users) { + await client.query( + ` + insert into identity.users ( + id, name, email, email_verified, email_verified_at, image, + created_at, updated_at + ) values ($1, $2, $3, $4, $5, $6, $7, $8) + on conflict (id) do update set + name = excluded.name, + email = excluded.email, + email_verified = excluded.email_verified, + email_verified_at = excluded.email_verified_at, + image = excluded.image, + created_at = excluded.created_at, + updated_at = excluded.updated_at + `, + [ + user.id, + user.name, + user.email, + user.emailVerified, + user.emailVerifiedAt, + user.image, + user.createdAt, + user.updatedAt, + ], + ); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } +} + +function parseArguments(arguments_) { + const apply = arguments_.includes("--apply"); + const positional = arguments_.filter((argument) => argument !== "--apply"); + if (positional.length !== 1 || arguments_.some((argument) => argument.startsWith("--") && argument !== "--apply")) { + throw new SafeImportError( + "usage: node scripts/import-supabase-auth-users.mjs [--apply]", + ); + } + return { apply, sourcePath: positional[0] }; +} + +async function loadUsers(sourcePath) { + let contents; + try { + contents = await readFile(sourcePath, "utf8"); + } catch { + throw new SafeImportError("unable to read source file"); + } + + try { + return normalizeSupabaseUsers(JSON.parse(contents)); + } catch (error) { + if (error instanceof SafeImportError) throw error; + throw new SafeImportError("source file is not valid JSON"); + } +} + +export async function main(arguments_, env) { + const { apply, sourcePath } = parseArguments(arguments_); + const users = await loadUsers(sourcePath); + const summary = { + mode: apply ? "apply" : "dry-run", + users: users.length, + verified: users.filter((user) => user.emailVerified).length, + }; + + if (!apply) { + process.stdout.write(`${JSON.stringify(summary)}\n`); + return; + } + + const databaseUrl = env.IDENTITY_DATABASE_URL?.trim(); + if (!databaseUrl) { + throw new SafeImportError("IDENTITY_DATABASE_URL is required for --apply"); + } + if (!databaseUrl.startsWith("postgresql://")) { + throw new SafeImportError("IDENTITY_DATABASE_URL must be a PostgreSQL URL"); + } + + const pool = new Pool({ + connectionString: databaseUrl, + options: "-c search_path=identity,pg_catalog", + application_name: "jyotisha-identity-import", + max: 1, + }); + try { + const client = await pool.connect(); + try { + await applyIdentityUsers(client, users); + } finally { + client.release(); + } + } catch { + throw new SafeImportError("identity user import failed"); + } finally { + await pool.end(); + } + process.stdout.write(`${JSON.stringify(summary)}\n`); +} + +const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedPath) { + main(process.argv.slice(2), process.env).catch((error) => { + const message = + error instanceof SafeImportError ? error.message : "identity user import failed"; + process.stderr.write(`${message}\n`); + process.exitCode = 1; + }); +} diff --git a/frontend/tests/fixtures/supabase-auth-users.json b/frontend/tests/fixtures/supabase-auth-users.json new file mode 100644 index 00000000..608f6cf9 --- /dev/null +++ b/frontend/tests/fixtures/supabase-auth-users.json @@ -0,0 +1,23 @@ +[ + { + "id": "018f4e6d-7a11-7000-8000-000000000001", + "email": " Person@Example.com ", + "email_confirmed_at": "2026-07-01T01:02:03.000Z", + "created_at": "2026-06-01T01:02:03.000Z", + "updated_at": "2026-07-02T01:02:03.000Z", + "raw_user_meta_data": { + "full_name": "Person One", + "avatar_url": "https://example.com/person.png" + }, + "encrypted_password": "must-not-be-imported", + "last_sign_in_at": "2026-07-10T01:02:03.000Z" + }, + { + "id": "018f4e6d-7a11-7000-8000-000000000002", + "email": "second@example.com", + "email_confirmed_at": null, + "created_at": "2026-06-02T01:02:03.000Z", + "updated_at": "2026-06-02T01:02:03.000Z", + "raw_user_meta_data": {} + } +] diff --git a/frontend/tests/identity-user-import.test.ts b/frontend/tests/identity-user-import.test.ts new file mode 100644 index 00000000..6aaf0bca --- /dev/null +++ b/frontend/tests/identity-user-import.test.ts @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + applyIdentityUsers, + normalizeSupabaseUsers, +} from "../scripts/import-supabase-auth-users.mjs"; + +const fixturePath = fileURLToPath( + new URL("./fixtures/supabase-auth-users.json", import.meta.url), +); +const scriptPath = fileURLToPath( + new URL("../scripts/import-supabase-auth-users.mjs", import.meta.url), +); + +test("Supabase user transform preserves portable identity fields only", () => { + const source = JSON.parse(readFileSync(fixturePath, "utf8")); + const users = normalizeSupabaseUsers(source); + + assert.deepEqual(users, [ + { + id: "018f4e6d-7a11-7000-8000-000000000001", + email: "person@example.com", + emailVerified: true, + emailVerifiedAt: new Date("2026-07-01T01:02:03.000Z"), + name: "Person One", + image: "https://example.com/person.png", + createdAt: new Date("2026-06-01T01:02:03.000Z"), + updatedAt: new Date("2026-07-02T01:02:03.000Z"), + }, + { + id: "018f4e6d-7a11-7000-8000-000000000002", + email: "second@example.com", + emailVerified: false, + emailVerifiedAt: null, + name: "second", + image: null, + createdAt: new Date("2026-06-02T01:02:03.000Z"), + updatedAt: new Date("2026-06-02T01:02:03.000Z"), + }, + ]); + const serialized = JSON.stringify(users); + assert.doesNotMatch( + serialized, + /encrypted_password|last_sign_in_at|raw_user_meta_data|must-not-be-imported/, + ); +}); + +test("Supabase user transform aborts duplicate canonical emails", () => { + const source = JSON.parse(readFileSync(fixturePath, "utf8")); + source.push({ + ...source[1], + id: "018f4e6d-7a11-7000-8000-000000000003", + email: " SECOND@example.com ", + }); + + assert.throws( + () => normalizeSupabaseUsers(source), + new Error("source contains duplicate canonical emails"), + ); +}); + +test("identity user apply is transactional, parameterized, and rerunnable", async () => { + const users = normalizeSupabaseUsers( + JSON.parse(readFileSync(fixturePath, "utf8")), + ); + const calls: Array<{ text: string; values?: unknown[] }> = []; + const client = { + async query(text: string, values?: unknown[]) { + calls.push({ text, values }); + return { rowCount: 1 }; + }, + }; + + await applyIdentityUsers(client, users); + await applyIdentityUsers(client, users); + + assert.equal(calls.filter((call) => call.text === "BEGIN").length, 2); + assert.equal(calls.filter((call) => call.text === "COMMIT").length, 2); + const upserts = calls.filter((call) => /insert into identity\.users/.test(call.text)); + assert.equal(upserts.length, 4); + assert.ok(upserts.every((call) => /on conflict \(id\) do update/.test(call.text))); + assert.ok(upserts.every((call) => /\$1/.test(call.text))); + assert.ok(upserts.every((call) => !call.text.includes("person@example.com"))); +}); + +test("CLI defaults to a redacted dry-run without a database URL", () => { + const env = { ...process.env }; + delete env.IDENTITY_DATABASE_URL; + const result = spawnSync(process.execPath, [scriptPath, fixturePath], { + encoding: "utf8", + env, + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /"mode":"dry-run"/); + assert.match(result.stdout, /"users":2/); + assert.doesNotMatch(result.stdout, /person@example\.com|second@example\.com/); +}); + +test("CLI apply mode requires an identity database URL without printing inputs", () => { + const env = { ...process.env }; + delete env.IDENTITY_DATABASE_URL; + const result = spawnSync( + process.execPath, + [scriptPath, fixturePath, "--apply"], + { encoding: "utf8", env }, + ); + + assert.notEqual(result.status, 0); + assert.match(result.stderr, /IDENTITY_DATABASE_URL is required for --apply/); + assert.doesNotMatch(result.stderr, /person@example\.com|must-not-be-imported/); +});