forked from wangziqi/gongxue-base
352 lines
12 KiB
JavaScript
352 lines
12 KiB
JavaScript
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
import pg from 'pg';
|
|
|
|
const APPLY_CONFIRMATION = 'BOOTSTRAP_FIRST_PLATFORM_ADMIN';
|
|
const BOOTSTRAP_LOCK_KEY = 'tiku-saas:first-platform-admin:v1';
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
|
|
function envString(env, key, fallback = '') {
|
|
return typeof env[key] === 'string' && env[key].trim() ? env[key].trim() : fallback;
|
|
}
|
|
|
|
function argumentValue(argv, name) {
|
|
const directIndex = argv.indexOf(name);
|
|
if (directIndex >= 0) return String(argv[directIndex + 1] || '').trim();
|
|
const prefix = `${name}=`;
|
|
return String(argv.find(value => value.startsWith(prefix)) || '').slice(prefix.length).trim();
|
|
}
|
|
|
|
function normalizeOptionalEmail(value) {
|
|
if (!value) return null;
|
|
const email = value.toLowerCase();
|
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) || email.length > 254) {
|
|
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_EMAIL must be a valid email address');
|
|
}
|
|
return email;
|
|
}
|
|
|
|
function normalizeOptionalPhone(value) {
|
|
if (!value) return null;
|
|
const phone = value.replace(/\s+/g, '');
|
|
if (!/^\+?[0-9-]{6,32}$/.test(phone)) {
|
|
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_PHONE must be a valid phone number');
|
|
}
|
|
return phone;
|
|
}
|
|
|
|
function normalizeUsername(value, fallback) {
|
|
const username = (value || fallback).trim();
|
|
if (!/^[a-zA-Z0-9_.@-]{3,80}$/.test(username)) {
|
|
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_USERNAME must contain 3-80 safe characters');
|
|
}
|
|
return username;
|
|
}
|
|
|
|
function buildConfig(env = process.env, argv = process.argv.slice(2)) {
|
|
const databaseUrl = envString(env, 'DATABASE_URL');
|
|
const authUserId = argumentValue(argv, '--auth-user-id') || envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID');
|
|
const apply = argv.includes('--apply');
|
|
const confirmation = argumentValue(argv, '--confirm') || envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_CONFIRM');
|
|
const email = normalizeOptionalEmail(envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_EMAIL'));
|
|
const phone = normalizeOptionalPhone(envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_PHONE'));
|
|
const username = normalizeUsername(
|
|
envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_USERNAME'),
|
|
`platform_${authUserId.slice(0, 8)}`,
|
|
);
|
|
const name = envString(env, 'BOOTSTRAP_PLATFORM_ADMIN_NAME', 'Platform Administrator');
|
|
|
|
if (!databaseUrl) throw new Error('Missing required env: DATABASE_URL');
|
|
if (!UUID_RE.test(authUserId)) {
|
|
throw new Error('BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID or --auth-user-id must be a valid UUID');
|
|
}
|
|
if (!name || name.length > 120) throw new Error('BOOTSTRAP_PLATFORM_ADMIN_NAME must contain 1-120 characters');
|
|
if (apply && confirmation !== APPLY_CONFIRMATION) {
|
|
throw new Error(`--apply requires --confirm ${APPLY_CONFIRMATION}`);
|
|
}
|
|
|
|
return { databaseUrl, authUserId, apply, confirmation, username, email, phone, name };
|
|
}
|
|
|
|
function maskEmail(value) {
|
|
if (!value) return null;
|
|
const [local = '', domain = ''] = String(value).split('@');
|
|
return `${local.slice(0, 2)}***@${domain}`;
|
|
}
|
|
|
|
function maskPhone(value) {
|
|
if (!value) return null;
|
|
const phone = String(value);
|
|
return phone.length > 7 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : '***';
|
|
}
|
|
|
|
function maskUuid(value) {
|
|
if (!value) return null;
|
|
const id = String(value);
|
|
return `${id.slice(0, 8)}...${id.slice(-4)}`;
|
|
}
|
|
|
|
function maskUsername(value) {
|
|
if (!value) return null;
|
|
const username = String(value);
|
|
if (username.includes('@')) return maskEmail(username);
|
|
if (/^\+?[0-9-]{6,32}$/.test(username)) return maskPhone(username);
|
|
if (username.length <= 3) return '***';
|
|
return `${username.slice(0, 2)}***${username.slice(-1)}`;
|
|
}
|
|
|
|
function publicResult(result) {
|
|
return {
|
|
dryRun: result.dryRun,
|
|
action: result.action,
|
|
platformUserId: maskUuid(result.platformUserId),
|
|
authUserId: maskUuid(result.authUserId),
|
|
username: maskUsername(result.username),
|
|
email: maskEmail(result.email),
|
|
phone: maskPhone(result.phone),
|
|
primaryRole: 'platform_admin',
|
|
status: 'active',
|
|
permissions: ['*'],
|
|
auditAction: result.auditAction || null,
|
|
};
|
|
}
|
|
|
|
function sanitizeErrorMessage(error, databaseUrl = '') {
|
|
let message = error instanceof Error ? error.message : String(error);
|
|
if (databaseUrl) message = message.split(databaseUrl).join('[DATABASE_URL_REDACTED]');
|
|
return message
|
|
.replace(/postgres(?:ql)?:\/\/[^\s'"<>]+/gi, '[DATABASE_URL_REDACTED]')
|
|
.replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@:]+:[^\s/@]+@/gi, '$1[REDACTED]@');
|
|
}
|
|
|
|
async function withTransaction(client, callback) {
|
|
await client.query('begin');
|
|
try {
|
|
await client.query('select pg_advisory_xact_lock(hashtextextended($1, 0))', [BOOTSTRAP_LOCK_KEY]);
|
|
const result = await callback();
|
|
await client.query('commit');
|
|
return result;
|
|
} catch (error) {
|
|
await client.query('rollback').catch(() => undefined);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function bootstrapPlatformAdmin(inputConfig, options = {}) {
|
|
const config = inputConfig?.databaseUrl
|
|
? inputConfig
|
|
: buildConfig(options.env || process.env, options.argv || process.argv.slice(2));
|
|
if (config.apply && config.confirmation !== APPLY_CONFIRMATION) {
|
|
throw new Error(`Apply requires confirmation ${APPLY_CONFIRMATION}`);
|
|
}
|
|
const pool = options.pool || new pg.Pool({ connectionString: config.databaseUrl, max: 1 });
|
|
const closePool = !options.pool;
|
|
|
|
try {
|
|
const client = await pool.connect();
|
|
try {
|
|
return await withTransaction(client, async () => {
|
|
const authUserResult = await client.query(
|
|
`
|
|
select app.auth_user_exists($1::uuid) as exists
|
|
`,
|
|
[config.authUserId],
|
|
);
|
|
if (!authUserResult.rows[0]?.exists) throw new Error('Supabase Auth user not found');
|
|
|
|
const activeBoundResult = await client.query(
|
|
`
|
|
select id
|
|
from public.platform_users
|
|
where primary_role = 'platform_admin'
|
|
and status = 'active'
|
|
and auth_user_id is not null
|
|
order by created_at asc
|
|
limit 2
|
|
for update
|
|
`,
|
|
);
|
|
if (activeBoundResult.rowCount > 0) {
|
|
throw new Error('An active, Auth-bound platform admin already exists; bootstrap is permanently refused');
|
|
}
|
|
|
|
const boundUserResult = await client.query(
|
|
`
|
|
select id, primary_role as "primaryRole"
|
|
from public.platform_users
|
|
where auth_user_id = $1::uuid
|
|
limit 1
|
|
for update
|
|
`,
|
|
[config.authUserId],
|
|
);
|
|
if (boundUserResult.rows[0]?.primaryRole !== undefined) {
|
|
throw new Error('Supabase Auth user is already bound to a platform user');
|
|
}
|
|
|
|
const legacyResult = await client.query(
|
|
`
|
|
select id, username, email::text, phone, name
|
|
from public.platform_users
|
|
where primary_role = 'platform_admin'
|
|
and auth_user_id is null
|
|
order by created_at asc
|
|
limit 2
|
|
for update
|
|
`,
|
|
);
|
|
if (legacyResult.rowCount > 1) {
|
|
throw new Error('Multiple unbound legacy platform admins exist; resolve the ambiguity manually');
|
|
}
|
|
|
|
const legacy = legacyResult.rows[0] || null;
|
|
const action = legacy ? 'bind_legacy' : 'create';
|
|
const platformUserId = legacy?.id || null;
|
|
const resolvedEmail = legacy?.email || config.email || null;
|
|
const resolvedPhone = legacy?.phone || config.phone || null;
|
|
const resolvedUsername = legacy?.username || config.username;
|
|
const resolvedName = legacy?.name || config.name;
|
|
|
|
if (!config.apply) {
|
|
return {
|
|
dryRun: true,
|
|
action,
|
|
platformUserId,
|
|
authUserId: config.authUserId,
|
|
username: resolvedUsername,
|
|
email: resolvedEmail,
|
|
phone: resolvedPhone,
|
|
auditAction: null,
|
|
};
|
|
}
|
|
|
|
const savedResult = await client.query(
|
|
`
|
|
insert into public.platform_users (
|
|
id, auth_user_id, username, email, phone, name,
|
|
primary_role, status, platform_permissions, raw_profile
|
|
)
|
|
values (
|
|
coalesce($1::uuid, pg_catalog.gen_random_uuid()), $2::uuid, $3, $4::extensions.citext, $5, $6,
|
|
'platform_admin', 'active', '{"*":true}'::jsonb,
|
|
jsonb_build_object('source', 'server-bootstrap', 'bootstrapVersion', 1)
|
|
)
|
|
on conflict (id)
|
|
do update set auth_user_id = excluded.auth_user_id,
|
|
username = coalesce(public.platform_users.username, excluded.username),
|
|
email = coalesce(public.platform_users.email, excluded.email),
|
|
phone = coalesce(public.platform_users.phone, excluded.phone),
|
|
name = coalesce(public.platform_users.name, excluded.name),
|
|
primary_role = 'platform_admin',
|
|
status = 'active',
|
|
platform_permissions = '{"*":true}'::jsonb,
|
|
raw_profile = jsonb_strip_nulls(public.platform_users.raw_profile || excluded.raw_profile),
|
|
updated_at = now()
|
|
returning id, auth_user_id as "authUserId", username, email::text, phone
|
|
`,
|
|
[platformUserId, config.authUserId, resolvedUsername, resolvedEmail, resolvedPhone, resolvedName],
|
|
);
|
|
const saved = savedResult.rows[0];
|
|
const auditAction = 'platform.admin.bootstrapped';
|
|
|
|
await client.query(
|
|
`
|
|
insert into public.audit_logs (
|
|
tenant_id, actor_user_id, action, target_type, target_id, details
|
|
)
|
|
values (
|
|
null, null, $2, 'platform_user', $1::text,
|
|
jsonb_build_object(
|
|
'source', 'server-bootstrap',
|
|
'invokedBy', 'system_cli',
|
|
'bootstrapVersion', 1,
|
|
'mode', $3::text,
|
|
'authUserBound', true,
|
|
'permissions', jsonb_build_array('*')
|
|
)
|
|
)
|
|
`,
|
|
[saved.id, auditAction, action],
|
|
);
|
|
|
|
return {
|
|
dryRun: false,
|
|
action,
|
|
platformUserId: saved.id,
|
|
authUserId: saved.authUserId,
|
|
username: saved.username,
|
|
email: saved.email,
|
|
phone: saved.phone,
|
|
auditAction,
|
|
};
|
|
});
|
|
} finally {
|
|
client.release();
|
|
}
|
|
} finally {
|
|
if (closePool) await pool.end();
|
|
}
|
|
}
|
|
|
|
function helpText() {
|
|
return `
|
|
Bootstrap the first production platform administrator from an existing Supabase Auth user.
|
|
|
|
Safety contract:
|
|
- Runs as a local server CLI and talks directly to DATABASE_URL.
|
|
- Defaults to dry-run. Writing requires --apply and the exact confirmation phrase.
|
|
- Refuses once any active, Auth-bound platform admin exists.
|
|
- May bind exactly one unbound legacy platform_admin row; multiple candidates are refused.
|
|
- Uses a boolean Auth existence boundary and never reads auth.users profile fields.
|
|
- Provide optional email/phone explicitly when a new business profile needs them.
|
|
- Grants {"*":true} and writes a redacted system-CLI audit event in the same locked transaction.
|
|
|
|
Usage:
|
|
DATABASE_URL=<server-database-url> \\
|
|
BOOTSTRAP_PLATFORM_ADMIN_AUTH_USER_ID=<existing-auth.users-id> \\
|
|
BOOTSTRAP_PLATFORM_ADMIN_USERNAME=<username> \\
|
|
BOOTSTRAP_PLATFORM_ADMIN_NAME=<display-name> \\
|
|
npm run bootstrap:platform-admin
|
|
|
|
npm run bootstrap:platform-admin -- --apply --confirm ${APPLY_CONFIRMATION}
|
|
|
|
Optional identity fields:
|
|
BOOTSTRAP_PLATFORM_ADMIN_EMAIL
|
|
BOOTSTRAP_PLATFORM_ADMIN_PHONE
|
|
`;
|
|
}
|
|
|
|
async function main() {
|
|
if (process.argv.includes('--help') || process.argv.includes('-h')) {
|
|
console.log(helpText().trim());
|
|
return;
|
|
}
|
|
let config;
|
|
try {
|
|
config = buildConfig();
|
|
const result = await bootstrapPlatformAdmin(config);
|
|
console.log(JSON.stringify({ ok: true, ...publicResult(result) }, null, 2));
|
|
if (result.dryRun) {
|
|
console.error(`Dry-run only. Re-run with --apply --confirm ${APPLY_CONFIRMATION} after reviewing the target.`);
|
|
}
|
|
} catch (error) {
|
|
console.error(sanitizeErrorMessage(error, config?.databaseUrl || envString(process.env, 'DATABASE_URL')));
|
|
console.error(helpText());
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
const currentFile = fileURLToPath(import.meta.url);
|
|
if (process.argv[1] && fileURLToPath(pathToFileURL(process.argv[1])) === currentFile) {
|
|
await main();
|
|
}
|
|
|
|
export {
|
|
APPLY_CONFIRMATION,
|
|
BOOTSTRAP_LOCK_KEY,
|
|
bootstrapPlatformAdmin,
|
|
buildConfig,
|
|
helpText,
|
|
publicResult,
|
|
sanitizeErrorMessage,
|
|
};
|