diff --git a/.env.example b/.env.example index e857a69..715be2a 100644 --- a/.env.example +++ b/.env.example @@ -3,5 +3,9 @@ BASE_URL=http://localhost:5173 ADMIN_EMAIL=admin@example.com REDIS_URL=redis://localhost:6379/0 +# One-time public bootstrap. Use a long random value, configure Google OAuth, +# then remove it before real users sign in. +# LIKEABLE_BOOTSTRAP_TOKEN= + # Local/private bootstrap only. Unset after Google OAuth is configured in Admin. LIKEABLE_DEV_AUTH=1 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..57429ed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + preflight: + name: Deploy preflight + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Install Bun + run: npm install -g bun@1.3.10 + + - name: Install frontend dependencies + run: bun install --frozen-lockfile + + - name: Run deploy preflight + run: bin/deploy-preflight diff --git a/Dockerfile b/Dockerfile index 4d20ca9..271a963 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,11 +22,11 @@ COPY internal ./internal COPY --from=frontend /src/dist ./internal/likeable/web-dist RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -trimpath -ldflags="-s -w -buildid=" -o /out/likeable ./cmd/likeable -FROM debian:bookworm-slim AS runtime -RUN apt-get update \ - && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates curl git \ - && rm -rf /var/lib/apt/lists/* -RUN useradd --uid 10001 --create-home --home-dir /home/likeable --shell /usr/sbin/nologin likeable && mkdir -p /data && chown -R likeable:likeable /data +FROM alpine:3.22 AS runtime +RUN apk add --no-cache ca-certificates curl git \ + && adduser -D -u 10001 -h /home/likeable -s /sbin/nologin likeable \ + && mkdir -p /data \ + && chown -R likeable:likeable /data COPY --from=backend /out/likeable /usr/local/bin/likeable RUN mkdir -p /usr/local/share/likeable COPY scripts/go-fibe-greenfield.yaml /usr/local/share/likeable/go-fibe-greenfield.yaml diff --git a/README.md b/README.md index d21f241..d99773b 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ After the first admin login, open Admin and configure: - GitHub OAuth for repository export. - Stripe prices and webhook secret. - SMTP delivery. -- Signup mode, free daily messages, and project cap. +- Signup mode, free build minutes/window, playground idle-stop hours, project cap, and paid playground slot duration. Signup defaults to forbidden until the admin changes it. @@ -64,6 +64,126 @@ To run the fully containerized app instead of the live-reload development server docker compose --profile app up --build ``` +Before deploying a test build, run the deploy preflight: + +```bash +bin/deploy-preflight +``` + +It runs the TypeScript check, frontend build, Go tests, production Docker image build, and a container smoke against `/healthz` and the admin billing health API. Override the image tag or smoke port with `LIKEABLE_IMAGE_TAG` and `LIKEABLE_PREFLIGHT_PORT`. + +After deploying a live test environment, run the remote readiness smoke: + +```bash +LIKEABLE_BASE_URL=https://likeable.example.com \ +LIKEABLE_ADMIN_EMAIL=admin@example.com \ +bin/live-readiness +``` + +For an environment without dev auth, pass an existing admin session cookie with `LIKEABLE_SESSION_COOKIE` instead of `LIKEABLE_ADMIN_EMAIL`. + +`bin/live-readiness` exits non-zero when readiness or billing has blockers. Set `LIKEABLE_ALLOW_BLOCKERS=1` to print the full diagnostic payload while a test environment is intentionally incomplete. + +To apply launch-only live config without printing secret values: + +```bash +LIKEABLE_BASE_URL=https://likeable.example.com \ +LIKEABLE_ADMIN_EMAIL=admin@example.com \ +LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS=8 \ +LIKEABLE_GOOGLE_CLIENT_ID=... \ +LIKEABLE_GOOGLE_CLIENT_SECRET=... \ +LIKEABLE_SMTP_HOST=smtp.example.com \ +LIKEABLE_SMTP_FROM_EMAIL=support@example.com \ +bin/live-configure +``` + +Optional SMTP env keys are `LIKEABLE_SMTP_PORT`, `LIKEABLE_SMTP_USERNAME`, `LIKEABLE_SMTP_PASSWORD`, `LIKEABLE_SMTP_FROM_NAME`, and `LIKEABLE_SMTP_TLS_MODE`. Use `LIKEABLE_DRY_RUN=1` to print the config keys that would be applied without changing the live environment. + +`bin/live-configure` validates common operator mistakes before it writes config: playground idle-stop hours must be between 1 and 168, Google client ID/secret must be supplied together, SMTP port must be numeric, and SMTP TLS mode must be one of `auto`, `tls`, `starttls`, or `none`. + +## Test Deployment Runbook + +Use this flow for the Vyakymenko Likeable test environment or any equivalent test VPS. + +The automated path is: + +```bash +LIKEABLE_DEPLOY_HOST=user@server.example.com \ +LIKEABLE_IMAGE_TAG=registry.example.com/likeable:test \ +LIKEABLE_BASE_URL=https://likeable.example.com \ +LIKEABLE_ADMIN_EMAIL=admin@example.com \ +bin/deploy-vps +``` + +`bin/deploy-vps` runs the local preflight, pushes the image, updates Redis and the app container over SSH, then waits for `/healthz`. Set `LIKEABLE_RUN_PREFLIGHT=0` or `LIKEABLE_PUSH_IMAGE=0` only when the image was already tested or already pushed. + +The manual path is: + +1. Run the local preflight and tag the exact image you want to deploy: + +```bash +LIKEABLE_IMAGE_TAG=registry.example.com/likeable:test bin/deploy-preflight +docker push registry.example.com/likeable:test +``` + +2. Run Redis and the app container with a persistent database volume: + +```bash +docker network create likeable-net || true +docker run -d --name likeable-redis \ + --restart unless-stopped \ + --network likeable-net \ + -v likeable_redis:/data \ + redis:7.4-alpine redis-server --appendonly yes --save 60 1 + +docker run -d --name likeable \ + --restart unless-stopped \ + --network likeable-net \ + -p 8080:8080 \ + -v likeable_data:/data \ + -e ADDR=:8080 \ + -e DATABASE_PATH=/data/likeable.db \ + -e BASE_URL=https://likeable.example.com \ + -e ADMIN_EMAIL=admin@example.com \ + -e REDIS_URL=redis://likeable-redis:6379/0 \ + registry.example.com/likeable:test +``` + +Put TLS and the public hostname in front of port `8080` with your reverse proxy. For a single small test host, the default `LIKEABLE_ROLE=all` is fine. On a busier host, run one `LIKEABLE_ROLE=web` container behind HTTP and one `LIKEABLE_ROLE=worker` container against the same database and Redis. + +3. Bootstrap admin access. + +For a private smoke only, temporarily set `LIKEABLE_DEV_AUTH=1` and sign in with `ADMIN_EMAIL`. For a public test URL, set `LIKEABLE_BOOTSTRAP_TOKEN` once, POST Google OAuth config to `/api/bootstrap/config`, then remove the token and restart before inviting users. + +4. Configure Admin. + +Set Fibe, Google OAuth, GitHub export, SMTP, signup mode, free minutes/window, playground idle-stop hours, project cap, paid project-slot duration, and Stripe: + +- `stripe_secret_key` +- `stripe_webhook_secret` +- `stripe_price_id_1_hour`, `stripe_price_id_10_hours`, `stripe_price_id_100_hours` +- `stripe_project_quota_price_id` + +Stripe webhook URL: `${BASE_URL}/api/stripe/webhook`. + +Checkout uses backend-created Stripe Checkout Sessions and does not require `stripe_publishable_key`. It also uses Stripe dynamic payment methods by not sending `payment_method_types`. Enable Link in the Stripe Dashboard payment method settings; the app will not override that Dashboard configuration. + +5. Smoke the user flows. + +- Admin launch readiness is `ready`, or every remaining failed check is an explicitly accepted rollout warning. +- Admin billing health has no blocking issues. +- A new user can sign in, create a project, and send a first message. +- Sending a message to a stopped project wakes the playground and then sends the prompt. +- Idle playgrounds pause after the configured inactive window; users can start them again when they return. +- Hour-pack checkout grants build minutes. +- Project-slot checkout increases the project cap for the configured number of days. +- Likeable does not sell production hosting or custom domains. For always-on production hosting and domain setup, continue the project in Fibe. +- Admin diagnostics for a user project shows conversation, agent, playground, server, repositories, payments, hour ledger, and work sessions. + +### Production Handoff + +Likeable is scoped to experiments and development playgrounds. Keep billing in Likeable limited to build-hour packs and extra playground slots. Production hosting, custom domains, always-on runtimes, and customer DNS work belong in Fibe. + ## Development With Live Reload The short development workflow mirrors the main Fibe app: diff --git a/bin/deploy-preflight b/bin/deploy-preflight new file mode 100755 index 0000000..9e5b5fe --- /dev/null +++ b/bin/deploy-preflight @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TAG="${LIKEABLE_IMAGE_TAG:-likeable:deploy-preflight}" +PORT="${LIKEABLE_PREFLIGHT_PORT:-18080}" +CONTAINER="likeable-preflight-$PORT" +GO_IMAGE="${LIKEABLE_GO_IMAGE:-golang:1.26.1-bookworm}" +COMMIT_SHA="$(git -C "$ROOT" rev-parse --short HEAD 2>/dev/null || echo local)" + +cleanup() { + docker stop "$CONTAINER" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +cd "$ROOT" + +echo "==> Operational script checks" +for script in bin/*; do + if [ -f "$script" ]; then + bash -n "$script" + fi +done +LIKEABLE_BASE_URL=http://localhost \ + LIKEABLE_DRY_RUN=1 \ + LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS=8 \ + LIKEABLE_GOOGLE_CLIENT_ID=google-client-preflight \ + LIKEABLE_GOOGLE_CLIENT_SECRET=google-secret-preflight \ + LIKEABLE_SMTP_HOST=smtp.example.test \ + LIKEABLE_SMTP_PORT=587 \ + LIKEABLE_SMTP_TLS_MODE=starttls \ + LIKEABLE_SMTP_FROM_EMAIL=support@example.test \ + bin/live-configure >/dev/null + +echo "==> TypeScript check" +npm run lint + +echo "==> Frontend build" +npm run build + +echo "==> Go tests" +docker run --rm \ + -v "$ROOT":/src \ + -v /tmp/likeable-go-mod:/go/pkg/mod \ + -v /tmp/likeable-go-build:/root/.cache/go-build \ + -w /src \ + "$GO_IMAGE" go test ./... + +echo "==> Docker image build: $TAG" +docker build \ + --build-arg "FIBE_BUILD_GIT_COMMIT_SHA=$COMMIT_SHA" \ + -t "$TAG" \ + . + +cleanup + +echo "==> Container smoke on http://localhost:$PORT" +docker run -d --rm \ + --name "$CONTAINER" \ + -p "$PORT:8080" \ + -e ADDR=:8080 \ + -e DATABASE_PATH=/data/likeable.db \ + -e BASE_URL="http://localhost:$PORT" \ + -e ADMIN_EMAIL=admin@example.com \ + -e LIKEABLE_DEV_AUTH=1 \ + "$TAG" >/dev/null + +for _ in $(seq 1 40); do + if curl -fsS "http://localhost:$PORT/healthz" >/dev/null 2>&1; then + break + fi + sleep 0.5 +done + +curl -fsS "http://localhost:$PORT/healthz" >/dev/null +cookie_jar="$(mktemp)" +curl -fsS -c "$cookie_jar" "http://localhost:$PORT/api/dev/login?email=admin@example.com" >/dev/null +curl -fsS -b "$cookie_jar" "http://localhost:$PORT/admin" | grep -q '
' +curl -fsS -b "$cookie_jar" "http://localhost:$PORT/api/admin/billing/health" >/dev/null +rm -f "$cookie_jar" + +image_id="$(docker image inspect "$TAG" --format '{{.Id}}')" +echo "==> OK: $TAG ($image_id)" diff --git a/bin/deploy-vps b/bin/deploy-vps new file mode 100755 index 0000000..188d6cb --- /dev/null +++ b/bin/deploy-vps @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +required() { + local name="$1" + local value="${!name:-}" + if [ -z "$value" ]; then + echo "missing required env: $name" >&2 + exit 2 + fi +} + +required LIKEABLE_DEPLOY_HOST +required LIKEABLE_IMAGE_TAG +required LIKEABLE_BASE_URL +required LIKEABLE_ADMIN_EMAIL + +HTTP_PORT="${LIKEABLE_HTTP_PORT:-8080}" +APP_NAME="${LIKEABLE_APP_NAME:-likeable}" +REDIS_NAME="${LIKEABLE_REDIS_NAME:-likeable-redis}" +NETWORK_NAME="${LIKEABLE_NETWORK_NAME:-likeable-net}" +RUN_PREFLIGHT="${LIKEABLE_RUN_PREFLIGHT:-1}" +PUSH_IMAGE="${LIKEABLE_PUSH_IMAGE:-1}" +BOOTSTRAP_TOKEN="${LIKEABLE_BOOTSTRAP_TOKEN:-}" +DEV_AUTH="${LIKEABLE_DEV_AUTH:-}" + +if [ "$RUN_PREFLIGHT" != "0" ]; then + echo "==> Local deploy preflight" + LIKEABLE_IMAGE_TAG="$LIKEABLE_IMAGE_TAG" "$ROOT/bin/deploy-preflight" +fi + +if [ "$PUSH_IMAGE" != "0" ]; then + echo "==> Push image: $LIKEABLE_IMAGE_TAG" + docker push "$LIKEABLE_IMAGE_TAG" +fi + +echo "==> Remote deploy: $LIKEABLE_DEPLOY_HOST" +ssh "$LIKEABLE_DEPLOY_HOST" bash -s -- \ + "$LIKEABLE_IMAGE_TAG" \ + "$LIKEABLE_BASE_URL" \ + "$LIKEABLE_ADMIN_EMAIL" \ + "$HTTP_PORT" \ + "$APP_NAME" \ + "$REDIS_NAME" \ + "$NETWORK_NAME" \ + "$BOOTSTRAP_TOKEN" \ + "$DEV_AUTH" <<'REMOTE' +set -euo pipefail + +IMAGE_TAG="$1" +BASE_URL="$2" +ADMIN_EMAIL="$3" +HTTP_PORT="$4" +APP_NAME="$5" +REDIS_NAME="$6" +NETWORK_NAME="$7" +BOOTSTRAP_TOKEN="$8" +DEV_AUTH="$9" + +container_exists() { + docker ps -a --format '{{.Names}}' | grep -Fx "$1" >/dev/null 2>&1 +} + +echo "==> Ensure Docker network" +docker network create "$NETWORK_NAME" >/dev/null 2>&1 || true + +echo "==> Ensure Redis" +if container_exists "$REDIS_NAME"; then + docker start "$REDIS_NAME" >/dev/null 2>&1 || true + docker network connect "$NETWORK_NAME" "$REDIS_NAME" >/dev/null 2>&1 || true +else + docker run -d \ + --name "$REDIS_NAME" \ + --restart unless-stopped \ + --network "$NETWORK_NAME" \ + -v "${REDIS_NAME}_data:/data" \ + redis:7.4-alpine redis-server --appendonly yes --save 60 1 >/dev/null +fi + +echo "==> Pull app image" +docker pull "$IMAGE_TAG" + +echo "==> Replace app container" +docker rm -f "$APP_NAME" >/dev/null 2>&1 || true + +env_args=() +if [ -n "$BOOTSTRAP_TOKEN" ]; then + env_args+=(-e "LIKEABLE_BOOTSTRAP_TOKEN=$BOOTSTRAP_TOKEN") +fi +if [ "$DEV_AUTH" = "1" ]; then + env_args+=(-e "LIKEABLE_DEV_AUTH=1") +fi + +docker run -d \ + --name "$APP_NAME" \ + --restart unless-stopped \ + --network "$NETWORK_NAME" \ + -p "$HTTP_PORT:8080" \ + -v "${APP_NAME}_data:/data" \ + -e ADDR=:8080 \ + -e DATABASE_PATH=/data/likeable.db \ + -e "BASE_URL=$BASE_URL" \ + -e "ADMIN_EMAIL=$ADMIN_EMAIL" \ + -e "REDIS_URL=redis://$REDIS_NAME:6379/0" \ + -e HOME=/tmp \ + "${env_args[@]}" \ + --tmpfs /tmp:size=256m,mode=1777 \ + --read-only \ + --security-opt no-new-privileges:true \ + --cap-drop ALL \ + --health-cmd 'curl -fsS http://127.0.0.1:8080/healthz || exit 1' \ + --health-interval 30s \ + --health-timeout 5s \ + --health-start-period 10s \ + --health-retries 3 \ + "$IMAGE_TAG" >/dev/null + +echo "==> Wait for health" +for _ in $(seq 1 60); do + if docker exec "$APP_NAME" curl -fsS http://127.0.0.1:8080/healthz >/dev/null 2>&1; then + echo "==> OK: $APP_NAME is healthy on port $HTTP_PORT" + exit 0 + fi + sleep 1 +done + +docker logs --tail 80 "$APP_NAME" >&2 || true +echo "app did not become healthy" >&2 +exit 1 +REMOTE diff --git a/bin/live-configure b/bin/live-configure new file mode 100755 index 0000000..f7d02d6 --- /dev/null +++ b/bin/live-configure @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${LIKEABLE_BASE_URL:-}" +ADMIN_EMAIL="${LIKEABLE_ADMIN_EMAIL:-}" +SESSION_COOKIE="${LIKEABLE_SESSION_COOKIE:-}" +DRY_RUN="${LIKEABLE_DRY_RUN:-0}" + +if [ -z "$BASE_URL" ]; then + echo "missing required env: LIKEABLE_BASE_URL" >&2 + exit 2 +fi + +BASE_URL="${BASE_URL%/}" +cookie_jar="$(mktemp)" +payload_file="$(mktemp)" +cleanup() { + rm -f "$cookie_jar" "$payload_file" +} +trap cleanup EXIT + +node - <<'NODE' >"$payload_file" +const mappings = [ + ["LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS", "playground_idle_stop_hours"], + ["LIKEABLE_GOOGLE_CLIENT_ID", "google_client_id"], + ["LIKEABLE_GOOGLE_CLIENT_SECRET", "google_client_secret"], + ["LIKEABLE_SMTP_HOST", "smtp_host"], + ["LIKEABLE_SMTP_PORT", "smtp_port"], + ["LIKEABLE_SMTP_USERNAME", "smtp_username"], + ["LIKEABLE_SMTP_PASSWORD", "smtp_password"], + ["LIKEABLE_SMTP_FROM_EMAIL", "smtp_from_email"], + ["LIKEABLE_SMTP_FROM_NAME", "smtp_from_name"], + ["LIKEABLE_SMTP_TLS_MODE", "smtp_tls_mode"], +]; +const payload = {}; +const provided = new Set(); +for (const [envName, configKey] of mappings) { + const value = process.env[envName]; + if (value !== undefined && value !== "") { + payload[configKey] = value; + provided.add(envName); + } +} +if (Object.keys(payload).length === 0) { + console.error("no config env vars were provided"); + process.exit(2); +} +const errors = []; +if (payload.playground_idle_stop_hours && !/^[0-9]+$/.test(payload.playground_idle_stop_hours)) { + errors.push("LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS must be numeric"); +} else if (payload.playground_idle_stop_hours) { + const hours = Number(payload.playground_idle_stop_hours); + if (hours < 1 || hours > 168) { + errors.push("LIKEABLE_PLAYGROUND_IDLE_STOP_HOURS must be between 1 and 168"); + } +} +if (provided.has("LIKEABLE_GOOGLE_CLIENT_ID") !== provided.has("LIKEABLE_GOOGLE_CLIENT_SECRET")) { + errors.push("LIKEABLE_GOOGLE_CLIENT_ID and LIKEABLE_GOOGLE_CLIENT_SECRET must be provided together"); +} +if (payload.smtp_port && !/^[0-9]+$/.test(payload.smtp_port)) { + errors.push("LIKEABLE_SMTP_PORT must be numeric"); +} +if (payload.smtp_tls_mode && !["auto", "tls", "starttls", "none"].includes(payload.smtp_tls_mode.trim().toLowerCase())) { + errors.push("LIKEABLE_SMTP_TLS_MODE must be one of auto, tls, starttls, none"); +} +if (errors.length > 0) { + for (const error of errors) { + console.error(error); + } + process.exit(2); +} +console.error(`config keys: ${Object.keys(payload).join(", ")}`); +process.stdout.write(JSON.stringify(payload)); +NODE + +if [ "$DRY_RUN" = "1" ]; then + echo "==> Dry run only; config was not changed" + exit 0 +fi + +if [ -n "$SESSION_COOKIE" ]; then + auth_args=(-H "Cookie: likeable_session=$SESSION_COOKIE") +else + if [ -z "$ADMIN_EMAIL" ]; then + echo "missing required env: LIKEABLE_ADMIN_EMAIL when LIKEABLE_SESSION_COOKIE is not set" >&2 + exit 2 + fi + echo "==> Admin auth: dev login" + curl -fsS -c "$cookie_jar" "$BASE_URL/api/dev/login?email=$ADMIN_EMAIL" >/dev/null + auth_args=(-b "$cookie_jar") +fi + +echo "==> Applying config to $BASE_URL" +curl -fsS "${auth_args[@]}" \ + -X PUT \ + -H 'Content-Type: application/json' \ + --data-binary "@$payload_file" \ + "$BASE_URL/api/admin/config" >/dev/null + +echo "==> OK: config keys applied" diff --git a/bin/live-readiness b/bin/live-readiness new file mode 100755 index 0000000..9ba5728 --- /dev/null +++ b/bin/live-readiness @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE_URL="${LIKEABLE_BASE_URL:-}" +ADMIN_EMAIL="${LIKEABLE_ADMIN_EMAIL:-}" +SESSION_COOKIE="${LIKEABLE_SESSION_COOKIE:-}" +ALLOW_BLOCKERS="${LIKEABLE_ALLOW_BLOCKERS:-0}" + +if [ -z "$BASE_URL" ]; then + echo "missing required env: LIKEABLE_BASE_URL" >&2 + exit 2 +fi + +BASE_URL="${BASE_URL%/}" +cookie_jar="$(mktemp)" +health_file="$(mktemp)" +readiness_file="$(mktemp)" +billing_file="$(mktemp)" +cleanup() { + rm -f "$cookie_jar" "$health_file" "$readiness_file" "$billing_file" +} +trap cleanup EXIT + +echo "==> Health: $BASE_URL/healthz" +curl -fsS "$BASE_URL/healthz" >"$health_file" +node -e 'const fs=require("fs"); const h=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); if (!h.ok) process.exit(1); console.log(JSON.stringify(h.checks || {}));' "$health_file" + +if [ -n "$SESSION_COOKIE" ]; then + auth_args=(-H "Cookie: likeable_session=$SESSION_COOKIE") +else + if [ -z "$ADMIN_EMAIL" ]; then + echo "missing required env: LIKEABLE_ADMIN_EMAIL when LIKEABLE_SESSION_COOKIE is not set" >&2 + exit 2 + fi + echo "==> Admin auth: dev login" + curl -fsS -c "$cookie_jar" "$BASE_URL/api/dev/login?email=$ADMIN_EMAIL" >/dev/null + auth_args=(-b "$cookie_jar") +fi + +echo "==> Admin page" +curl -fsS "${auth_args[@]}" "$BASE_URL/admin" | grep -q '
' +echo "ok" + +echo "==> Launch readiness" +curl -fsS "${auth_args[@]}" "$BASE_URL/api/admin/readiness" >"$readiness_file" +node -e ' +const fs=require("fs"); +const r=JSON.parse(fs.readFileSync(process.argv[1],"utf8")).readiness; +const failed=(r.checks || []).filter((check)=>!check.ok); +console.log(JSON.stringify({ready:r.ready, blockers:r.blockerCount, warnings:r.warningCount, failed:failed.map((check)=>({key:check.key,severity:check.severity,detail:check.detail || ""}))}, null, 2)); +if (!r.ready) process.exit(3); +' "$readiness_file" || readiness_status=$? +readiness_status="${readiness_status:-0}" +if [ "$readiness_status" != "0" ] && [ "$ALLOW_BLOCKERS" != "1" ]; then + exit "$readiness_status" +fi + +echo "==> Billing health" +curl -fsS "${auth_args[@]}" "$BASE_URL/api/admin/billing/health" >"$billing_file" +node -e ' +const fs=require("fs"); +const h=JSON.parse(fs.readFileSync(process.argv[1],"utf8")).health; +console.log(JSON.stringify({issues:h.issues || [], products:h.products || {}, prices:h.prices || {}}, null, 2)); +if ((h.issues || []).length > 0) process.exit(4); +' "$billing_file" || billing_status=$? +billing_status="${billing_status:-0}" +if [ "$billing_status" != "0" ] && [ "$ALLOW_BLOCKERS" != "1" ]; then + exit "$billing_status" +fi + +if [ "$readiness_status" = "0" ] && [ "$billing_status" = "0" ]; then + echo "==> OK: live readiness is clear" +else + echo "==> Completed with accepted live blockers" +fi diff --git a/fibe-template.yml b/fibe-template.yml index 42d8076..4b2c4d3 100644 --- a/fibe-template.yml +++ b/fibe-template.yml @@ -6,7 +6,7 @@ x-fibe.gg: default: "https://github.com/fibegg/likeable" REPO_BRANCH: name: Repository branch - default: "likeable-launch-hardening" + default: "main" SUBDOMAIN: name: Subdomain default: "@" diff --git a/frontend/src/admin.tsx b/frontend/src/admin.tsx index 521c809..b28997c 100644 --- a/frontend/src/admin.tsx +++ b/frontend/src/admin.tsx @@ -4,7 +4,7 @@ import { cleanPoolRows, makePoolRow, parsePoolRows } from './admin_pool'; import { api } from './api'; import { AppDialog, Metric } from './builder_components'; import { ADMIN_CONFIG_SECTIONS } from './config'; -import type { AdminConfigEntry, AdminConfigResponse, AdminProjectSummary, AdminRecoveryResponse, AdminUserDetail, AdminUserSummary, AdminUsersResponse, AgentAssignmentSummary, AgentPoolOption, AgentPoolStat, AppDialogConfig, PoolRow } from './domain'; +import type { AdminBillingHealth, AdminBillingHealthResponse, AdminConfigEntry, AdminConfigResponse, AdminProjectDiagnostics, AdminProjectDiagnosticsResponse, AdminProjectSummary, AdminReadiness, AdminReadinessResponse, AdminRecoveryResponse, AdminUserDetail, AdminUserSummary, AdminUsersResponse, AgentAssignmentSummary, AgentPoolHealth, AgentPoolOption, AgentPoolStat, AppDialogConfig, PoolRow } from './domain'; import { formatBillingDuration, formatMessageTime, formatShortDate, userInitials } from './format'; import { resetCountdownLabels, statusLabel, TranslationKey, useDocumentTitle, useI18n } from './i18n'; @@ -20,6 +20,9 @@ function AdminCustomersPanel() { const [loading, setLoading] = useState(false); const [actionError, setActionError] = useState(''); const [reassigningProjectID, setReassigningProjectID] = useState(''); + const [diagnosticsProjectID, setDiagnosticsProjectID] = useState(''); + const [diagnostics, setDiagnostics] = useState(null); + const [diagnosticsLoadingID, setDiagnosticsLoadingID] = useState(''); const [accessNote, setAccessNote] = useState(''); const [noticeBody, setNoticeBody] = useState(''); const [noticeSeverity, setNoticeSeverity] = useState('warning'); @@ -223,7 +226,25 @@ function AdminCustomersPanel() { setReassigningProjectID(''); } }; - + const loadProjectDiagnostics = async (projectID: string) => { + if (!selectedUserID) return; + if (diagnosticsProjectID === projectID) { + setDiagnosticsProjectID(''); + setDiagnostics(null); + return; + } + setActionError(''); + setDiagnosticsLoadingID(projectID); + try { + const response = await api(`/api/admin/users/${selectedUserID}/projects/${projectID}/diagnostics`); + setDiagnosticsProjectID(projectID); + setDiagnostics(response.diagnostics); + } catch (err) { + setActionError(err instanceof Error ? err.message : t('admin.diagnostics.failed')); + } finally { + setDiagnosticsLoadingID(''); + } + }; return (
{dialog && setDialog(null)} />} @@ -428,32 +449,74 @@ function AdminCustomersPanel() { const assignmentValue = pairValue(assignment?.agentId ?? '', assignment?.serverId ?? ''); const canReassign = options.some((option) => option.status === 'active') && item.project.status !== 'archived' && item.project.status !== 'deleting'; const previewUrl = item.project.previewUrl; + const projectDiagnostics = diagnosticsProjectID === item.project.id ? diagnostics : null; return ( -
- - {item.project.title} - {statusLabel(item.project.status, t)} · {workDuration(item.workMs)} - - -
- {previewUrl && {t('common.open')}} - +
+
+ + {item.project.title} + {statusLabel(item.project.status, t)} · {workDuration(item.workMs)} + + +
+ {previewUrl && {t('common.open')}} + + +
+ {projectDiagnostics && ( +
+
+ {diagnosticEntries(projectDiagnostics).map(([label, value]) => ( + + {label} + {value || '—'} + + ))} +
+
+
+ {t('admin.diagnostics.workSessions')} + {projectDiagnostics.workSessions.slice(0, 4).map((session) => ( + {session.sessionKey} · {workDuration(session.freeBilledMs)}/{workDuration(session.paidBilledMs)} · {formatMessageTime(session.startedAt, locale)} + ))} + {projectDiagnostics.workSessions.length === 0 && {t('admin.diagnostics.empty')}} +
+
+ {t('admin.diagnostics.hourLedger')} + {projectDiagnostics.hourLedger.slice(0, 4).map((entry) => ( + {entry.reason} · {workDuration(entry.deltaMs)} · {entry.paymentId || entry.workSessionKey || '—'} + ))} + {projectDiagnostics.hourLedger.length === 0 && {t('admin.diagnostics.empty')}} +
+
+ {t('admin.billingHealth.recentPayments')} + {projectDiagnostics.payments.slice(0, 4).map((payment) => ( + {formatMoney(payment.amountCents, payment.currency)} · {payment.status} · {payment.providerPaymentId} + ))} + {projectDiagnostics.payments.length === 0 && {t('admin.diagnostics.empty')}} +
+
+
+ )}
); })} @@ -473,6 +536,29 @@ function formatMoney(cents: number, currency: string): string { return `${normalized} ${(cents / 100).toFixed(2)}`; } +function diagnosticEntries(diagnostics: AdminProjectDiagnostics): [string, string][] { + const internal = diagnostics.internal; + const services = diagnostics.project.services ?? []; + const repositories = diagnostics.project.repositories ?? []; + return [ + ['project_id', diagnostics.project.id], + ['conversation_id', internal.conversationId ?? ''], + ['agent_id', internal.agentId ?? ''], + ['server_id', internal.serverId ?? ''], + ['playground_id', internal.playgroundId ?? ''], + ['playground_name', internal.playgroundName ?? ''], + ['playspec_id', internal.playspecId ?? ''], + ['prop_id', internal.propId ?? ''], + ['repo_url', internal.repoUrl ?? ''], + ['selected_service', diagnostics.project.selectedServiceName ?? ''], + ['services', services.map((service) => `${service.name}:${service.url}`).join(' | ')], + ['repositories', repositories.map((repository) => `${repository.role}:${repository.sourceRepoUrl || repository.id}`).join(' | ')], + ['internal_error', internal.internalErrorMessage ?? ''], + ['cleanup_error', internal.cleanupLastError ?? ''], + ['provisioning_lock_until', internal.provisioningLockUntil ?? ''] + ]; +} + function pairValue(agentId: string, serverId: string): string { return JSON.stringify([agentId, serverId]); } @@ -534,6 +620,199 @@ function poolStatusLabel(status: string | undefined, t: (key: TranslationKey) => } } +function poolHealthLabel(health: AgentPoolHealth | undefined, t: (key: TranslationKey, params?: Record) => string): string { + if (!health) return t('admin.pool.health.unknown'); + if (health.ok) return t('admin.pool.health.ok'); + const problem = health.problems?.find(Boolean); + return problem ? t('admin.pool.health.problem', { problem }) : t('admin.pool.health.warning'); +} + +function formatAdminMoney(cents: number, currency: string, locale: string): string { + const code = (currency || 'usd').toUpperCase(); + try { + return new Intl.NumberFormat(locale, { style: 'currency', currency: code }).format((cents || 0) / 100); + } catch { + return `${((cents || 0) / 100).toFixed(2)} ${code}`; + } +} + +function billingIssueLabel(issue: string, t: (key: TranslationKey, params?: Record) => string): string { + const keys: Record = { + stripe_publishable_missing: 'admin.billingHealth.issue.publishable', + stripe_secret_missing: 'admin.billingHealth.issue.secret', + stripe_webhook_missing: 'admin.billingHealth.issue.webhook', + stripe_hour_prices_missing: 'admin.billingHealth.issue.hourPrices', + stripe_project_quota_price_missing: 'admin.billingHealth.issue.projectQuota' + }; + const key = keys[issue]; + return key ? t(key) : t('admin.billingHealth.issue.unknown', { issue }); +} + +function AdminBillingHealthPanel() { + const { locale, t } = useI18n(); + const [health, setHealth] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const load = async () => { + setLoading(true); + setError(''); + try { + const response = await api('/api/admin/billing/health'); + setHealth(response.health); + } catch (err) { + setError(err instanceof Error ? err.message : t('admin.billingHealth.loadFailed')); + } finally { + setLoading(false); + } + }; + useEffect(() => { + void load(); + }, []); + const configuredCount = health ? Object.values(health.configured).filter(Boolean).length : 0; + const hourPacks = health?.products.hourPacks ?? []; + const productsLabel = [ + hourPacks.length > 0 ? t('admin.billingHealth.hourPacks', { packs: hourPacks.map((pack) => `${pack}h`).join(', ') }) : t('admin.billingHealth.noHourPacks'), + health?.products.projectQuota ? t('admin.billingHealth.projectQuotaOn') : t('admin.billingHealth.projectQuotaOff') + ].join(' · '); + return ( +
+
+
+

{t('admin.billingHealth.title')}

+

{t('admin.billingHealth.body')}

+
+ +
+ {error &&
{error}
} +
+ + + + +
+ {health && ( + <> + {health.issues.length === 0 ? ( +
{t('admin.billingHealth.noIssues')}
+ ) : ( +
+ {health.issues.map((issue) => ( +
+ + {t('admin.billingHealth.issue')} + {billingIssueLabel(issue, t)} + + {issue} +
+ ))} +
+ )} +
+

{t('admin.billingHealth.recentPayments')}

+

{health.checkedAt ? t('admin.billingHealth.checkedAt', { time: formatMessageTime(health.checkedAt, locale) }) : ''}

+
+ {health.recentPayments.length === 0 ? ( +
{t('admin.billingHealth.noPayments')}
+ ) : ( +
+ {health.recentPayments.map((payment) => ( +
+ + {formatAdminMoney(payment.amountCents, payment.currency, locale)} · {payment.status} + {payment.userEmail || payment.userId} · {formatMessageTime(payment.createdAt, locale)} + + {payment.providerPaymentId} +
+ ))} +
+ )} + + )} +
+ ); +} + +function readinessCheckLabel(key: string, t: (key: TranslationKey, params?: Record) => string): string { + const keys: Record = { + stripe_secret: 'admin.readiness.check.stripeSecret', + stripe_webhook: 'admin.readiness.check.stripeWebhook', + stripe_hour_prices: 'admin.readiness.check.hourPrices', + stripe_project_quota_price: 'admin.readiness.check.projectQuotaPrice', + fibe_template_version: 'admin.readiness.check.fibeTemplate', + fibe_active_pool: 'admin.readiness.check.activePool', + fibe_active_pool_health: 'admin.readiness.check.activePoolHealth', + google_oauth: 'admin.readiness.check.googleOauth', + smtp_delivery: 'admin.readiness.check.smtp', + signup_enabled: 'admin.readiness.check.signup' + }; + const labelKey = keys[key]; + return labelKey ? t(labelKey) : t('admin.readiness.check.unknown', { key }); +} + +function AdminReadinessPanel() { + const { locale, t } = useI18n(); + const [readiness, setReadiness] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const load = async () => { + setLoading(true); + setError(''); + try { + const response = await api('/api/admin/readiness'); + setReadiness(response.readiness); + } catch (err) { + setError(err instanceof Error ? err.message : t('admin.readiness.loadFailed')); + } finally { + setLoading(false); + } + }; + useEffect(() => { + void load(); + }, []); + const failedChecks = readiness?.checks.filter((check) => !check.ok) ?? []; + return ( +
+
+
+

{t('admin.readiness.title')}

+

{t('admin.readiness.body')}

+
+ +
+ {error &&
{error}
} +
+ + + + +
+ {readiness && ( + failedChecks.length === 0 ? ( +
{t('admin.readiness.noIssues')}
+ ) : ( +
+ {failedChecks.map((check) => ( +
+ + {readinessCheckLabel(check.key, t)} + {check.detail || (check.severity === 'warning' ? t('admin.readiness.warning') : t('admin.readiness.blocker'))} + + {check.key} +
+ ))} +
+ ) + )} +
+ ); +} + function adminConfigLabel(key: string, t: (key: TranslationKey) => string): string { const labelKeys: Record = { github_client_id: 'admin.config.github_client_id', @@ -549,10 +828,12 @@ function adminConfigLabel(key: string, t: (key: TranslationKey) => string): stri stripe_price_id_100_hours: 'admin.config.stripe_price_id_100_hours', stripe_project_quota_price_id: 'admin.config.stripe_project_quota_price_id', stripe_webhook_secret: 'admin.config.stripe_webhook_secret', - free_hours: 'admin.config.free_hours', + free_minutes: 'admin.config.free_minutes', free_hour_window_hours: 'admin.config.free_hour_window_hours', + playground_idle_stop_hours: 'admin.config.playground_idle_stop_hours', prompt_improve_charge_minutes: 'admin.config.prompt_improve_charge_minutes', project_cap: 'admin.config.project_cap', + project_quota_days: 'admin.config.project_quota_days', agent_artefacts: 'admin.config.agent_artefacts' }; const labelKey = labelKeys[key]; @@ -568,6 +849,7 @@ export function Admin() { const [allowedEmails, setAllowedEmails] = useState(''); const [poolRows, setPoolRows] = useState([]); const [poolStats, setPoolStats] = useState([]); + const [poolHealth, setPoolHealth] = useState([]); const [retiringPoolRowID, setRetiringPoolRowID] = useState(''); const [saving, setSaving] = useState(false); const [status, setStatus] = useState(''); @@ -584,6 +866,7 @@ export function Admin() { setAllowedEmails(response.config.signup_allowed_emails?.value ?? ''); setPoolRows(parsePoolRows(response.config.fibe_agent_server_pool?.value ?? '[]')); setPoolStats(response.agentPoolStats ?? []); + setPoolHealth(response.agentPoolHealth ?? []); }; const loadRecovery = async () => { @@ -607,6 +890,7 @@ export function Admin() { setPoolRows((rows) => rows.map((row) => row.id === id ? { ...row, ...patch } : row)); }; const statForPoolRow = (row: PoolRow) => poolStats.find((stat) => stat.agentId === row.agentId.trim() && stat.serverId === row.serverId.trim()); + const healthForPoolRow = (row: PoolRow) => poolHealth.find((health) => health.agentId === row.agentId.trim() && health.serverId === row.serverId.trim()); const retirePoolRow = async (row: PoolRow) => { setRetiringPoolRowID(row.id); setStatus(''); @@ -686,6 +970,8 @@ export function Admin() {
+ +
@@ -805,9 +1091,17 @@ export function Admin() {
{(() => { const stat = statForPoolRow(row); - return stat + const health = healthForPoolRow(row); + const stats = stat ? t('admin.pool.stats', { projects: stat.projectCount, active: stat.activeProjectCount ?? Math.max(0, stat.projectCount - stat.archivedCount), archived: stat.archivedCount, archives: stat.readyArchiveCount }) : t('admin.pool.stats.empty'); + const healthLabel = poolHealthLabel(health, t); + return ( + <> + {stats} + {healthLabel} + + ); })()}
); @@ -545,6 +545,21 @@ export function CanvasLoader({ title, body, tone }: { title: string; body: strin function CanvasFrameDecor({ loading }: { loading?: boolean } = {}) { return ( <> + {loading && ( + + )}
+
+
+ {t('profile.scope')} + {t('profile.scopeTitle')} + {t('profile.scopeBody')} +
+ + {t('profile.openFibe')} + +
{t('profile.hours')} @@ -236,7 +247,7 @@ export function ProfilePanel({ me, onClose, onOpenTutorial, onRefreshAccount }: )} {projectQuota && } - {projectSlotsFull ? t('profile.projectSlotFullDetail') : t('profile.projectSlotDetail', { paid: projectQuota?.paidSlots ?? 0, reset: projectQuota?.nextExpiresAt ? ` · ${t('common.nextReset', { date: formatShortDate(projectQuota.nextExpiresAt, locale) })}` : '' })} + {projectSlotsFull ? t('profile.projectSlotFullDetail') : t('profile.projectSlotDetail', { paid: projectQuota?.paidSlots ?? 0, days: projectQuotaDays, reset: projectQuota?.nextExpiresAt ? ` · ${t('common.nextReset', { date: formatShortDate(projectQuota.nextExpiresAt, locale) })}` : '' })}
{projectQuotaPurchasable && (