Fix authenticated smoke bootstrap flow
This commit is contained in:
@@ -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:<HOST_PORT>` 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`.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-xl py-10">
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h1 className="text-xl font-semibold">Instance setup required</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
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:"}
|
||||
</p>
|
||||
<pre className="mt-4 overflow-x-auto rounded-md border border-border bg-muted/30 p-3 text-xs">
|
||||
{`pnpm paperclipai auth bootstrap-ceo`}
|
||||
@@ -78,7 +79,7 @@ function CloudAccessGate() {
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") {
|
||||
return <BootstrapPendingPage />;
|
||||
return <BootstrapPendingPage hasActiveInvite={healthQuery.data.bootstrapInviteActive} />;
|
||||
}
|
||||
|
||||
if (isAuthenticatedMode && !sessionQuery.data) {
|
||||
|
||||
@@ -4,6 +4,7 @@ export type HealthStatus = {
|
||||
deploymentExposure?: "private" | "public";
|
||||
authReady?: boolean;
|
||||
bootstrapStatus?: "ready" | "bootstrap_pending";
|
||||
bootstrapInviteActive?: boolean;
|
||||
features?: {
|
||||
companyDeletionEnabled?: boolean;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user