Zero-terminal first boot. Previously the bootstrap_ceo invite had to be
created via a CLI command (paperclipai auth bootstrap-ceo) and the UI
showed a code block instructing the user to run it. Nexus is meant to
be zero-terminal, so the server now auto-creates the invite on startup
when no instance admin exists and exposes its relative path through
/api/health. BootstrapPendingPage redirects straight to /invite/{token}.
The CLI command is left intact for headless/SSH-only setups.
Invite flow fixes that surfaced during testing:
- InviteLanding's invite query had default React Query refetch
behavior. After a successful bootstrap accept, the invite is marked
accepted server-side, so the refetch returned "not available" and
shadowed the success screen, making it look like the bootstrap had
failed when it actually succeeded. Set staleTime: Infinity +
refetchOnWindowFocus/Mount/Reconnect: false so the first fetch is a
one-shot snapshot.
- Reordered the render checks so result?.kind === "bootstrap" / "join"
are evaluated before the invite-availability error check — defensive
against any stray refetch that still leaks through.
- On bootstrap success, window.location.replace("/") lands the new
admin directly on the board; the "Bootstrap complete" confirmation
screen is now an unreachable safety net.
Vite onnxruntime middleware replaces the earlier public/ dump. The
previous commit put ort-wasm-simd-threaded.{mjs,wasm} in ui/public/ so
VAD's onnxWASMBasePath: "/" would find them. That works at runtime but
trips vite's dep optimizer: it scans onnxruntime-web, resolves the
dynamic import string to the public asset, and errors with "files in
/public should not be imported from source code." Remove the files and
add a vite plugin (configureServer middleware) that serves the two URLs
straight from node_modules/.pnpm/onnxruntime-web@*/. Runtime keeps
working and the files never enter vite's module graph.
Production build caveat: the middleware only runs in dev. When building
a static dist for production, the wasm files will need a different
mechanism (e.g. generateBundle hook). Not addressed here.
Also bundled (load-bearing for LAN browser testing):
- ui/src/lib/queryKeys.ts: add missing 'nexus' group. useNexusMode
referenced queryKeys.nexus.settings since commit 7bb72a5a (Phase
33-02) but the key was never added. Caused a blank screen crash on
any page that mounts Sidebar.
- ctl.sh: read PORT from .env instead of hardcoding 3100, and read it
once at the top so every subcommand honors it. Fixes the Version /
Mode showing '?' in status output after the port move to 6100.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
63 lines
2.1 KiB
TypeScript
63 lines
2.1 KiB
TypeScript
// [nexus] Auto-create a bootstrap_ceo invite on first boot so the web UI can
|
|
// redirect the first user to /invite/{token} without any terminal command.
|
|
// Mirrors the logic in cli/src/commands/auth-bootstrap-ceo.ts; the CLI command
|
|
// remains available for headless / SSH-only setups.
|
|
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { and, count, eq, gt, isNull } from "drizzle-orm";
|
|
import type { Db } from "@paperclipai/db";
|
|
import { instanceUserRoles, invites } from "@paperclipai/db";
|
|
|
|
function hashToken(token: string) {
|
|
return createHash("sha256").update(token).digest("hex");
|
|
}
|
|
|
|
function createInviteToken() {
|
|
return `pcp_bootstrap_${randomBytes(24).toString("hex")}`;
|
|
}
|
|
|
|
/**
|
|
* If no instance_admin user exists, create a bootstrap_ceo invite and return
|
|
* the relative path the browser should navigate to in order to create the
|
|
* first admin account. Returns null if an admin already exists (no-op).
|
|
*
|
|
* Safe to call on every boot — when an admin already exists it's a single
|
|
* count query with no writes.
|
|
*/
|
|
export async function ensureBootstrapInvite(db: Db): Promise<string | null> {
|
|
const adminCount = await db
|
|
.select({ count: count() })
|
|
.from(instanceUserRoles)
|
|
.where(eq(instanceUserRoles.role, "instance_admin"))
|
|
.then((rows) => Number(rows[0]?.count ?? 0));
|
|
|
|
if (adminCount > 0) return null;
|
|
|
|
const now = new Date();
|
|
// Revoke any stale live invites so our fresh token is the only match.
|
|
// We can't recover the raw token from the stored hash, so the previous
|
|
// invite is unreachable and must be replaced on restart.
|
|
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 = 72;
|
|
await db.insert(invites).values({
|
|
inviteType: "bootstrap_ceo",
|
|
tokenHash: hashToken(token),
|
|
allowedJoinTypes: "human",
|
|
expiresAt: new Date(Date.now() + expiresHours * 60 * 60 * 1000),
|
|
invitedByUserId: "system",
|
|
});
|
|
|
|
return `/invite/${token}`;
|
|
}
|