134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import * as p from "@clack/prompts";
|
|
import pc from "picocolors";
|
|
import { and, eq, gt, isNull } from "drizzle-orm";
|
|
import { createDb, instanceUserRoles, invites } from "@paperclipai/db";
|
|
import { loadPaperclipEnvFile } from "../config/env.js";
|
|
import { readConfig, resolveConfigPath } from "../config/store.js";
|
|
|
|
function hashToken(token: string) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
function createInviteToken() {
|
|
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
|
}
|
|
|
|
function resolveDbUrl(configPath?: string, explicitDbUrl?: string) {
|
|
if (explicitDbUrl) return explicitDbUrl;
|
|
const config = readConfig(configPath);
|
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
|
if (config?.database.mode === "postgres" && config.database.connectionString) {
|
|
return config.database.connectionString;
|
|
}
|
|
if (config?.database.mode === "embedded-postgres") {
|
|
const port = config.database.embeddedPostgresPort ?? 54329;
|
|
return `postgres://paperclip:paperclip@127.0.0.1:${port}/paperclip`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function resolveBaseUrl(configPath?: string, explicitBaseUrl?: string) {
|
|
if (explicitBaseUrl) return explicitBaseUrl.replace(/\/+$/, "");
|
|
const fromEnv =
|
|
process.env.PAPERCLIP_PUBLIC_URL ??
|
|
process.env.PAPERCLIP_AUTH_PUBLIC_BASE_URL ??
|
|
process.env.BETTER_AUTH_URL ??
|
|
process.env.BETTER_AUTH_BASE_URL;
|
|
if (fromEnv?.trim()) return fromEnv.trim().replace(/\/+$/, "");
|
|
const config = readConfig(configPath);
|
|
if (config?.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl) {
|
|
return config.auth.publicBaseUrl.replace(/\/+$/, "");
|
|
}
|
|
const host = config?.server.host ?? "localhost";
|
|
const port = config?.server.port ?? 3100;
|
|
const publicHost = host === "0.0.0.0" ? "localhost" : host;
|
|
return `http://${publicHost}:${port}`;
|
|
}
|
|
|
|
export async function bootstrapCeoInvite(opts: {
|
|
config?: string;
|
|
force?: boolean;
|
|
expiresHours?: number;
|
|
baseUrl?: string;
|
|
dbUrl?: string;
|
|
}) {
|
|
const configPath = resolveConfigPath(opts.config);
|
|
loadPaperclipEnvFile(configPath);
|
|
const config = readConfig(configPath);
|
|
if (!config) {
|
|
p.log.error(`No config found at ${configPath}. Run ${pc.cyan("paperclip onboard")} first.`);
|
|
return;
|
|
}
|
|
|
|
if (config.server.deploymentMode !== "authenticated") {
|
|
p.log.info("Deployment mode is local_trusted. Bootstrap CEO invite is only required for authenticated mode.");
|
|
return;
|
|
}
|
|
|
|
const dbUrl = resolveDbUrl(configPath, opts.dbUrl);
|
|
if (!dbUrl) {
|
|
p.log.error(
|
|
"Could not resolve database connection for bootstrap.",
|
|
);
|
|
return;
|
|
}
|
|
|
|
const db = createDb(dbUrl);
|
|
const closableDb = db as typeof db & {
|
|
$client?: {
|
|
end?: (options?: { timeout?: number }) => Promise<void>;
|
|
};
|
|
};
|
|
try {
|
|
const existingAdminCount = await db
|
|
.select()
|
|
.from(instanceUserRoles)
|
|
.where(eq(instanceUserRoles.role, "instance_admin"))
|
|
.then((rows) => rows.length);
|
|
|
|
if (existingAdminCount > 0 && !opts.force) {
|
|
p.log.info("Instance already has an admin user. Use --force to generate a new bootstrap invite.");
|
|
return;
|
|
}
|
|
|
|
const now = new Date();
|
|
await db
|
|
.update(invites)
|
|
.set({ revokedAt: now, updatedAt: now })
|
|
.where(
|
|
and(
|
|
eq(invites.inviteType, "bootstrap_ceo"),
|
|
isNull(invites.revokedAt),
|
|
isNull(invites.acceptedAt),
|
|
gt(invites.expiresAt, now),
|
|
),
|
|
);
|
|
|
|
const token = createInviteToken();
|
|
const expiresHours = Math.max(1, Math.min(24 * 30, opts.expiresHours ?? 72));
|
|
const created = await db
|
|
.insert(invites)
|
|
.values({
|
|
inviteType: "bootstrap_ceo",
|
|
tokenHash: hashToken(token),
|
|
allowedJoinTypes: "human",
|
|
expiresAt: new Date(Date.now() + expiresHours * 60 * 60 * 1000),
|
|
invitedByUserId: "system",
|
|
})
|
|
.returning()
|
|
.then((rows) => rows[0]);
|
|
|
|
const baseUrl = resolveBaseUrl(configPath, opts.baseUrl);
|
|
const inviteUrl = `${baseUrl}/invite/${token}`;
|
|
p.log.success("Created bootstrap CEO invite.");
|
|
p.log.message(`Invite URL: ${pc.cyan(inviteUrl)}`);
|
|
p.log.message(`Expires: ${pc.dim(created.expiresAt.toISOString())}`);
|
|
} catch (err) {
|
|
p.log.error(`Could not create bootstrap invite: ${err instanceof Error ? err.message : String(err)}`);
|
|
p.log.info("If using embedded-postgres, start the Paperclip server and run this command again.");
|
|
} finally {
|
|
await closableDb.$client?.end?.({ timeout: 5 }).catch(() => undefined);
|
|
}
|
|
}
|