feat(identity): add auth user import tool
This commit is contained in:
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
]
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in New Issue
Block a user