diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 9cc867db..82559bf8 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -123,5 +123,6 @@ Notes: - Smoke script defaults to `authenticated/private` mode so `HOST=0.0.0.0` can be exposed to the host. - Smoke script defaults host port to `3131` to avoid conflicts with local Paperclip on `3100`. - Smoke script also defaults `PAPERCLIP_PUBLIC_URL` to `http://localhost:` so bootstrap invite URLs and auth callbacks use the reachable host port instead of the container's internal `3100`. +- In authenticated mode, the smoke script defaults `SMOKE_AUTO_BOOTSTRAP=true` and drives the real bootstrap path automatically: it signs up a real user, runs `paperclipai auth bootstrap-ceo` inside the container to mint a real bootstrap invite, accepts that invite over HTTP, and verifies board session access. - Run the script in the foreground to watch the onboarding flow; stop with `Ctrl+C` after validation. - The image definition is in `Dockerfile.onboard-smoke`. diff --git a/scripts/docker-onboard-smoke.sh b/scripts/docker-onboard-smoke.sh index 3b3e24d8..c441a623 100755 --- a/scripts/docker-onboard-smoke.sh +++ b/scripts/docker-onboard-smoke.sh @@ -10,14 +10,187 @@ HOST_UID="${HOST_UID:-$(id -u)}" PAPERCLIP_DEPLOYMENT_MODE="${PAPERCLIP_DEPLOYMENT_MODE:-authenticated}" PAPERCLIP_DEPLOYMENT_EXPOSURE="${PAPERCLIP_DEPLOYMENT_EXPOSURE:-private}" PAPERCLIP_PUBLIC_URL="${PAPERCLIP_PUBLIC_URL:-http://localhost:${HOST_PORT}}" -DOCKER_TTY_ARGS=() - -if [[ -t 0 && -t 1 ]]; then - DOCKER_TTY_ARGS=(-it) -fi +SMOKE_AUTO_BOOTSTRAP="${SMOKE_AUTO_BOOTSTRAP:-true}" +SMOKE_ADMIN_NAME="${SMOKE_ADMIN_NAME:-Smoke Admin}" +SMOKE_ADMIN_EMAIL="${SMOKE_ADMIN_EMAIL:-smoke-admin@paperclip.local}" +SMOKE_ADMIN_PASSWORD="${SMOKE_ADMIN_PASSWORD:-paperclip-smoke-password}" +CONTAINER_NAME="${IMAGE_NAME//[^a-zA-Z0-9_.-]/-}" +LOG_PID="" +COOKIE_JAR="" +TMP_DIR="" mkdir -p "$DATA_DIR" +cleanup() { + if [[ -n "$LOG_PID" ]]; then + kill "$LOG_PID" >/dev/null 2>&1 || true + fi + docker stop "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then + rm -rf "$TMP_DIR" + fi +} + +trap cleanup EXIT INT TERM + +wait_for_http() { + local url="$1" + local attempts="${2:-60}" + local sleep_seconds="${3:-1}" + local i + for ((i = 1; i <= attempts; i += 1)); do + if curl -fsS "$url" >/dev/null 2>&1; then + return 0 + fi + sleep "$sleep_seconds" + done + return 1 +} + +generate_bootstrap_invite_url() { + local bootstrap_output + bootstrap_output="$( + docker exec \ + -e PAPERCLIP_DEPLOYMENT_MODE="$PAPERCLIP_DEPLOYMENT_MODE" \ + -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ + -e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \ + -e PAPERCLIP_HOME="/paperclip" \ + "$CONTAINER_NAME" bash -lc \ + 'npx --yes "paperclipai@${PAPERCLIPAI_VERSION}" auth bootstrap-ceo --data-dir "$PAPERCLIP_HOME" --base-url "$PAPERCLIP_PUBLIC_URL"' \ + 2>&1 + )" || { + echo "Smoke bootstrap failed: could not run bootstrap-ceo inside container" >&2 + printf '%s\n' "$bootstrap_output" >&2 + return 1 + } + + local invite_url + invite_url="$( + printf '%s\n' "$bootstrap_output" \ + | grep -o 'https\?://[^[:space:]]*/invite/pcp_bootstrap_[[:alnum:]]*' \ + | tail -n 1 + )" + + if [[ -z "$invite_url" ]]; then + echo "Smoke bootstrap failed: bootstrap-ceo did not print an invite URL" >&2 + printf '%s\n' "$bootstrap_output" >&2 + return 1 + fi + + printf '%s\n' "$invite_url" +} + +post_json_with_cookies() { + local url="$1" + local body="$2" + local output_file="$3" + curl -sS \ + -o "$output_file" \ + -w "%{http_code}" \ + -c "$COOKIE_JAR" \ + -b "$COOKIE_JAR" \ + -H "Content-Type: application/json" \ + -H "Origin: $PAPERCLIP_PUBLIC_URL" \ + -X POST \ + "$url" \ + --data "$body" +} + +get_with_cookies() { + local url="$1" + curl -fsS \ + -c "$COOKIE_JAR" \ + -b "$COOKIE_JAR" \ + -H "Accept: application/json" \ + "$url" +} + +sign_up_or_sign_in() { + local signup_response="$TMP_DIR/signup.json" + local signup_status + signup_status="$(post_json_with_cookies \ + "$PAPERCLIP_PUBLIC_URL/api/auth/sign-up/email" \ + "{\"name\":\"$SMOKE_ADMIN_NAME\",\"email\":\"$SMOKE_ADMIN_EMAIL\",\"password\":\"$SMOKE_ADMIN_PASSWORD\"}" \ + "$signup_response")" + if [[ "$signup_status" =~ ^2 ]]; then + echo " Smoke bootstrap: created admin user $SMOKE_ADMIN_EMAIL" + return 0 + fi + + local signin_response="$TMP_DIR/signin.json" + local signin_status + signin_status="$(post_json_with_cookies \ + "$PAPERCLIP_PUBLIC_URL/api/auth/sign-in/email" \ + "{\"email\":\"$SMOKE_ADMIN_EMAIL\",\"password\":\"$SMOKE_ADMIN_PASSWORD\"}" \ + "$signin_response")" + if [[ "$signin_status" =~ ^2 ]]; then + echo " Smoke bootstrap: signed in existing admin user $SMOKE_ADMIN_EMAIL" + return 0 + fi + + echo "Smoke bootstrap failed: could not sign up or sign in admin user" >&2 + echo "Sign-up response:" >&2 + cat "$signup_response" >&2 || true + echo >&2 + echo "Sign-in response:" >&2 + cat "$signin_response" >&2 || true + echo >&2 + return 1 +} + +auto_bootstrap_authenticated_smoke() { + local health_url="$PAPERCLIP_PUBLIC_URL/api/health" + local health_json + health_json="$(curl -fsS "$health_url")" + if [[ "$health_json" != *'"deploymentMode":"authenticated"'* ]]; then + return 0 + fi + + sign_up_or_sign_in + + if [[ "$health_json" == *'"bootstrapStatus":"ready"'* ]]; then + echo " Smoke bootstrap: instance already ready" + else + local invite_url + invite_url="$(generate_bootstrap_invite_url)" + echo " Smoke bootstrap: generated bootstrap invite via auth bootstrap-ceo" + + local invite_token="${invite_url##*/}" + local accept_response="$TMP_DIR/accept.json" + local accept_status + accept_status="$(post_json_with_cookies \ + "$PAPERCLIP_PUBLIC_URL/api/invites/$invite_token/accept" \ + '{"requestType":"human"}' \ + "$accept_response")" + if [[ ! "$accept_status" =~ ^2 ]]; then + echo "Smoke bootstrap failed: bootstrap invite acceptance returned HTTP $accept_status" >&2 + cat "$accept_response" >&2 || true + echo >&2 + return 1 + fi + echo " Smoke bootstrap: accepted bootstrap invite" + fi + + local session_json + session_json="$(get_with_cookies "$PAPERCLIP_PUBLIC_URL/api/auth/get-session")" + if [[ "$session_json" != *'"userId"'* ]]; then + echo "Smoke bootstrap failed: no authenticated session after bootstrap" >&2 + echo "$session_json" >&2 + return 1 + fi + + local companies_json + companies_json="$(get_with_cookies "$PAPERCLIP_PUBLIC_URL/api/companies")" + if [[ "${companies_json:0:1}" != "[" ]]; then + echo "Smoke bootstrap failed: board companies endpoint did not return JSON array" >&2 + echo "$companies_json" >&2 + return 1 + fi + + echo " Smoke bootstrap: board session verified" + echo " Smoke admin credentials: $SMOKE_ADMIN_EMAIL / $SMOKE_ADMIN_PASSWORD" +} + echo "==> Building onboard smoke image" docker build \ --build-arg PAPERCLIPAI_VERSION="$PAPERCLIPAI_VERSION" \ @@ -29,12 +202,15 @@ docker build \ echo "==> Running onboard smoke container" echo " UI should be reachable at: http://localhost:$HOST_PORT" echo " Public URL: $PAPERCLIP_PUBLIC_URL" +echo " Smoke auto-bootstrap: $SMOKE_AUTO_BOOTSTRAP" echo " Data dir: $DATA_DIR" echo " Deployment: $PAPERCLIP_DEPLOYMENT_MODE/$PAPERCLIP_DEPLOYMENT_EXPOSURE" echo " Live output: onboard banner and server logs stream in this terminal (Ctrl+C to stop)" -docker run --rm \ - "${DOCKER_TTY_ARGS[@]}" \ - --name "${IMAGE_NAME//[^a-zA-Z0-9_.-]/-}" \ + +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + +docker run -d --rm \ + --name "$CONTAINER_NAME" \ -p "$HOST_PORT:3100" \ -e HOST=0.0.0.0 \ -e PORT=3100 \ @@ -42,4 +218,21 @@ docker run --rm \ -e PAPERCLIP_DEPLOYMENT_EXPOSURE="$PAPERCLIP_DEPLOYMENT_EXPOSURE" \ -e PAPERCLIP_PUBLIC_URL="$PAPERCLIP_PUBLIC_URL" \ -v "$DATA_DIR:/paperclip" \ - "$IMAGE_NAME" + "$IMAGE_NAME" >/dev/null + +docker logs -f "$CONTAINER_NAME" & +LOG_PID=$! + +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-onboard-smoke.XXXXXX")" +COOKIE_JAR="$TMP_DIR/cookies.txt" + +if ! wait_for_http "$PAPERCLIP_PUBLIC_URL/api/health" 90 1; then + echo "Smoke bootstrap failed: server did not become ready at $PAPERCLIP_PUBLIC_URL/api/health" >&2 + exit 1 +fi + +if [[ "$SMOKE_AUTO_BOOTSTRAP" == "true" && "$PAPERCLIP_DEPLOYMENT_MODE" == "authenticated" ]]; then + auto_bootstrap_authenticated_smoke +fi + +wait "$LOG_PID" diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 9a95f6e8..ddc7c441 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import type { Db } from "@paperclipai/db"; -import { count, sql } from "drizzle-orm"; -import { instanceUserRoles } from "@paperclipai/db"; +import { and, count, eq, gt, isNull, sql } from "drizzle-orm"; +import { instanceUserRoles, invites } from "@paperclipai/db"; import type { DeploymentExposure, DeploymentMode } from "@paperclipai/shared"; export function healthRoutes( @@ -27,6 +27,7 @@ export function healthRoutes( } let bootstrapStatus: "ready" | "bootstrap_pending" = "ready"; + let bootstrapInviteActive = false; if (opts.deploymentMode === "authenticated") { const roleCount = await db .select({ count: count() }) @@ -34,6 +35,23 @@ export function healthRoutes( .where(sql`${instanceUserRoles.role} = 'instance_admin'`) .then((rows) => Number(rows[0]?.count ?? 0)); bootstrapStatus = roleCount > 0 ? "ready" : "bootstrap_pending"; + + if (bootstrapStatus === "bootstrap_pending") { + const now = new Date(); + const inviteCount = await db + .select({ count: count() }) + .from(invites) + .where( + and( + eq(invites.inviteType, "bootstrap_ceo"), + isNull(invites.revokedAt), + isNull(invites.acceptedAt), + gt(invites.expiresAt, now), + ), + ) + .then((rows) => Number(rows[0]?.count ?? 0)); + bootstrapInviteActive = inviteCount > 0; + } } res.json({ @@ -42,6 +60,7 @@ export function healthRoutes( deploymentExposure: opts.deploymentExposure, authReady: opts.authReady, bootstrapStatus, + bootstrapInviteActive, features: { companyDeletionEnabled: opts.companyDeletionEnabled, }, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 18df83d8..ee6db712 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -32,14 +32,15 @@ import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; -function BootstrapPendingPage() { +function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { return (

Instance setup required

- No instance admin exists yet. Run this command in your Paperclip environment to generate - the first admin invite URL: + {hasActiveInvite + ? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:" + : "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"}

 {`pnpm paperclipai auth bootstrap-ceo`}
@@ -78,7 +79,7 @@ function CloudAccessGate() {
   }
 
   if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
-    return ;
+    return ;
   }
 
   if (isAuthenticatedMode && !sessionQuery.data) {
diff --git a/ui/src/api/health.ts b/ui/src/api/health.ts
index 614bb522..cb1b1374 100644
--- a/ui/src/api/health.ts
+++ b/ui/src/api/health.ts
@@ -4,6 +4,7 @@ export type HealthStatus = {
   deploymentExposure?: "private" | "public";
   authReady?: boolean;
   bootstrapStatus?: "ready" | "bootstrap_pending";
+  bootstrapInviteActive?: boolean;
   features?: {
     companyDeletionEnabled?: boolean;
   };