feat(identity): add auth user import tool

This commit is contained in:
Jesse_Chen
2026-07-21 17:43:13 +08:00
parent dfcc718fa7
commit aae8cb5c21
3 changed files with 334 additions and 0 deletions
@@ -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 <export.json> [--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;
});
}
+23
View File
@@ -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": {}
}
]
+116
View File
@@ -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/);
});