diff --git a/Dockerfile b/Dockerfile index 1ae99c1..01c40ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1 -# ---------- Build the Rust backend (compiled here, in the build stage) ---------- +# ---------- Build the Rust backend ---------- FROM rust:1.94-bookworm AS backend-build WORKDIR /app @@ -17,10 +17,30 @@ COPY backend/migrations ./migrations RUN cargo build --release -# ---------- Runtime: prebuilt backend + frontend built at startup ---------- -# The frontend is built in this (run) stage, not at image-build time, so VITE_* -# values provided when the container starts are inlined into the bundle. +# ---------- Build the frontend with placeholder config ---------- +# The frontend is compiled once, here, with sentinel VITE_* values. The runtime +# entrypoint find-and-replaces those sentinels with the real configuration when +# the container starts, so one image can be deployed with any config — no rebuild +# and no build step at startup. +FROM node:20-slim AS frontend-build + +RUN npm install -g bun + +WORKDIR /app/frontend + +COPY frontend/package.json frontend/bun.lock ./ +RUN bun install + +COPY frontend ./ + +ENV VITE_API_URL=__VITE_API_URL__ \ + VITE_RELAY_DOMAIN=__VITE_RELAY_DOMAIN__ \ + VITE_PLATFORM_NAME=__VITE_PLATFORM_NAME__ +RUN bun run build + +# ---------- Runtime ---------- # node:20-slim is bookworm-based, so it is ABI-compatible with the backend binary. +# No bun / frontend sources here — just the prebuilt bundle and a static server. FROM node:20-slim RUN apt-get update \ @@ -28,33 +48,40 @@ RUN apt-get update \ ca-certificates \ libsqlite3-0 \ && rm -rf /var/lib/apt/lists/* \ - && npm install -g bun serve + && npm install -g serve WORKDIR /app -# Install frontend deps as a cached image layer. The actual `vite build` runs at -# startup (see the entrypoint below) so it can pick up runtime env vars. -COPY frontend/package.json frontend/bun.lock ./frontend/ -RUN cd frontend && bun install - -COPY frontend ./frontend - -# Prebuilt backend binary from the build stage. COPY --from=backend-build /app/target/release/backend /app/backend +COPY --from=frontend-build /app/frontend/dist /app/dist -# Single entrypoint: build the frontend with the runtime config, then run both -# processes and exit (so the orchestrator restarts us) if either one dies. +# Single entrypoint: substitute the real config into the prebuilt bundle, then +# run both processes and exit (so the orchestrator restarts us) if either dies. RUN cat > /app/entrypoint.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail -echo "Building frontend with runtime configuration..." -(cd /app/frontend && bun run build) +# Map the provided runtime variables onto the frontend's VITE_* placeholders. +VITE_API_URL="${SERVER_URL:-}" +VITE_RELAY_DOMAIN="${RELAY_DOMAIN:-}" +VITE_PLATFORM_NAME="${PLATFORM_NAME:-}" + +# Escape characters that are special in a sed replacement. +esc() { printf '%s' "$1" | sed -e 's/[&|\\]/\\&/g'; } + +echo "Applying runtime configuration to the frontend bundle..." +while IFS= read -r -d '' f; do + sed -i \ + -e "s|__VITE_API_URL__|$(esc "$VITE_API_URL")|g" \ + -e "s|__VITE_RELAY_DOMAIN__|$(esc "$VITE_RELAY_DOMAIN")|g" \ + -e "s|__VITE_PLATFORM_NAME__|$(esc "$VITE_PLATFORM_NAME")|g" \ + "$f" +done < <(find /app/dist -type f \( -name '*.js' -o -name '*.html' \) -print0) echo "Starting backend (:2892) and frontend (:3000)..." /app/backend & backend_pid=$! -serve -s /app/frontend/dist -l 3000 & +serve -s /app/dist -l 3000 & serve_pid=$! trap 'kill -TERM "$backend_pid" "$serve_pid" 2>/dev/null || true' TERM INT diff --git a/README.md b/README.md index 818dc19..0f8dd86 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,64 @@ A multi-tenant platform for hosting Nostr community relays, built on top of [zooid](https://github.com/coracle-social/zooid). -## Quick Start (Local Development) +## Deployment + +Caravel ships as a single Docker image, built from the repository-root [`Dockerfile`](./Dockerfile) and published by [`.gitea/workflows/docker-publish.yml`](./.gitea/workflows/docker-publish.yml) as `gitea.coracle.social/coracle/caravel`. One container runs both services: the backend API on port `2892` and the frontend on port `3000`. + +Both the backend and the frontend are compiled into the image at build time. The frontend is built with placeholder config that the entrypoint replaces with the real values when the container starts, so one image can be deployed with any configuration — no rebuild required. + +Caravel needs a reachable [zooid](https://github.com/coracle-social/zooid) instance (the [Local Development](#local-development) section below shows how to run one). Substitute your own values for the placeholders below: + +```sh +docker run -d \ + --name caravel \ + -p 2892:2892 \ + -p 3000:3000 \ + -v my-caravel-data:/app/data \ + -e PLATFORM_NAME=Caravel \ + -e RELAY_DOMAIN=example.com \ + -e APP_URL=https://example.com \ + -e ZOOID_API_URL=http://zooid:3334 \ + -e DATABASE_URL=sqlite://data/caravel.db \ + -e SERVER_URL=https://api.example.com \ + -e SERVER_PORT=2892 \ + -e SERVER_ADMIN_PUBKEYS= \ + -e SERVER_ALLOW_ORIGINS=https://example.com \ + -e ROBOT_SECRET= \ + -e ROBOT_NAME=Caravel \ + -e ROBOT_DESCRIPTION="Relay manager bot" \ + -e ROBOT_PICTURE=https://example.com/robot.png \ + -e ROBOT_WALLET= \ + -e ROBOT_OUTBOX_RELAYS=wss://relay.damus.io,wss://relay.primal.net,wss://nos.lol \ + -e ROBOT_INDEXER_RELAYS=wss://purplepag.es,wss://relay.damus.io,wss://indexer.coracle.social \ + -e ROBOT_MESSAGING_RELAYS=wss://auth.nostr1.com,wss://relay.keychat.io,wss://relay.ditto.pub \ + -e BLOSSOM_S3_ENDPOINT=https://s3.example.com \ + -e BLOSSOM_S3_REGION=us-east-1 \ + -e BLOSSOM_S3_BUCKET=caravel-blossom \ + -e BLOSSOM_S3_ACCESS_KEY= \ + -e BLOSSOM_S3_SECRET_KEY= \ + -e LIVEKIT_URL=wss://livekit.example.com \ + -e LIVEKIT_API_KEY= \ + -e LIVEKIT_API_SECRET= \ + -e STRIPE_SECRET_KEY= \ + gitea.coracle.social/coracle/caravel +``` + +Notes: + +- Every backend variable above is **required** — the server exits on startup if any is missing or empty. See [`backend/.env.template`](./backend/.env.template) for what each one means. +- `ROBOT_SECRET` is the robot account's hex nostr secret key; its pubkey must be in zooid's `API_WHITELIST` (the backend signs zooid requests with it via NIP-98). +- `SERVER_ALLOW_ORIGINS` must include the frontend's public origin, or browsers will be blocked by CORS. +- The frontend's `VITE_` prefixed env variables are automatically populated from the provided env variables. +- `-v my-caravel-data:/app/data` persists the SQLite database. + +To build the image yourself instead of pulling it: + +```sh +docker build -t caravel . +``` + +## Local Development ### Prerequisites diff --git a/backend/README.md b/backend/README.md index 6791d53..743dc57 100644 --- a/backend/README.md +++ b/backend/README.md @@ -16,17 +16,16 @@ Rust backend for Caravel. It manages tenants, relays, invoices, and background w backend/ migrations/ 0001_init.sql - spec/ # Module-by-module design notes src/ main.rs # App bootstrap: load Env, build services, serve + spawn workers env.rs # Configuration from the environment (+ NIP-44 encryption, NIP-98 signing) api.rs # Shared Api state, router, NIP-98 auth + authorization helpers web.rs # HTTP response envelope + helpers - routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices, stripe) + routes/ # HTTP route handlers (identity, plans, tenants, relays, invoices) models.rs # Domain models + sqlite rows query.rs # Database reads command.rs # Database writes + activity broadcast - pool.rs # SQLite pool + migrations + db.rs # SQLite pool, migrations, activity broadcast channel billing.rs # Stripe subscription reconciliation + Lightning collection worker stripe.rs # Thin Stripe REST client wallet.rs # NWC wallet handle (NIP-47) @@ -43,10 +42,11 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev | Variable | Description | | ---------------------- | ------------------------------------------------------------------- | -| `SERVER_HOST` | API bind host; also the value the NIP-98 `u` tag must contain | -| `SERVER_PORT` | API bind port | +| `SERVER_URL` | Public API base URL; the value the NIP-98 `u` tag must equal exactly | +| `SERVER_PORT` | API bind port (the server always binds to `127.0.0.1`) | | `SERVER_ADMIN_PUBKEYS` | Comma-separated admin pubkeys (hex) | | `SERVER_ALLOW_ORIGINS` | Comma-separated allowed CORS origins | +| `APP_URL` | Frontend base URL; used to build links in DMs and invoices | | `DATABASE_URL` | SQLite URL; relative `sqlite://` paths are resolved under `backend/` | **Robot identity** @@ -84,18 +84,15 @@ All configuration is read from the environment by `Env::load()` at startup. **Ev **Billing (Stripe)** -| Variable | Description | -| ----------------------- | ----------------------------------------------------------------------- | -| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | -| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret used to verify `Stripe-Signature` headers | -| `STRIPE_PRICE_BASIC` | Stripe price ID (`price_...`) backing the Basic plan | -| `STRIPE_PRICE_GROWTH` | Stripe price ID (`price_...`) backing the Growth plan | +| Variable | Description | +| ------------------- | ----------------------------------------------------- | +| `STRIPE_SECRET_KEY` | Stripe API secret key used for billing API operations | Comma-separated list variables are split on commas and trimmed; empty entries are dropped. ## Schema and Architecture -See [spec](spec) for more details +The database schema lives in [migrations](migrations); see the module-level doc comments in `src/` for architecture details. ## API Routes @@ -105,7 +102,6 @@ Public exceptions: - `GET /plans` - `GET /plans/:id` -- `POST /stripe/webhook` (validated with Stripe signatures) - `GET /identity` — get auth identity (`pubkey`, `is_admin`); side-effect-free - `GET /tenants` — list tenants (admin) @@ -122,16 +118,19 @@ Public exceptions: - `POST /relays/:id/deactivate` — deactivate relay (admin or relay tenant) - `POST /relays/:id/reactivate` — reactivate relay (admin or relay tenant) - `GET /tenants/:pubkey/invoices` — list tenant invoices (admin or same tenant) +- `GET /tenants/:pubkey/invoices/draft` — get the tenant's synthetic draft invoice for the current period, or `null` if nothing is due (admin or same tenant) +- `GET /tenants/:pubkey/invoices/draft/items` — list the draft invoice's line items (admin or same tenant) - `GET /invoices/:id` — get invoice (admin or same tenant) - `GET /invoices/:id/bolt11` — get invoice bolt11 (admin or same tenant) -- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (admin or same tenant) +- `GET /invoices/:id/items` — list invoice line items (admin or same tenant) +- `GET /tenants/:pubkey/stripe/session` — create Stripe customer portal session (same tenant only) ## API Auth Model Caravel intentionally uses a session-style variant of NIP-98 for client-to-backend API auth. - Frontend signs one kind `27235` event with `u = VITE_API_URL` and caches that header for about 10 minutes. -- Backend verifies event kind, signature, and that `u` contains configured `SERVER_HOST`. +- Backend verifies event kind, signature, and that `u` equals configured `SERVER_URL`. - Backend intentionally does not bind auth to exact request URL/method/query, and does not enforce payload hash, timestamp freshness window, or replay cache. - Goal: reduce repeated wallet signing prompts and avoid cookie-based sessions. - Tradeoff: this is weaker request-intent binding than strict NIP-98 semantics. diff --git a/frontend/src/lib/state.ts b/frontend/src/lib/state.ts index 4fe7cf3..018d58f 100644 --- a/frontend/src/lib/state.ts +++ b/frontend/src/lib/state.ts @@ -25,7 +25,7 @@ export type EventSigner = { signEvent(event: UnsignedEvent): Promise } -export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME || "Caravel" +export const PLATFORM_NAME = import.meta.env.VITE_PLATFORM_NAME export const eventStore = new EventStore() export const pool = new RelayPool()