diff --git a/.gitignore b/.gitignore index c74d7db..afb0270 100644 --- a/.gitignore +++ b/.gitignore @@ -31,8 +31,12 @@ tempest-*.tar # Ignore digested assets cache. /priv/static/cache_manifest.json -# Local SQLite data directory. +# Local SQLite data directories. /priv/tempest_dev/ +/data/ + +# Local deployment secrets. +/conf/.env # In case you use Node.js/npm, you want to ignore these. npm-debug.log diff --git a/conf/.env.example b/conf/.env.example new file mode 100644 index 0000000..20fa4ad --- /dev/null +++ b/conf/.env.example @@ -0,0 +1,69 @@ +# Tempest production environment template. +# +# Copy to conf/.env for docker compose, source/translate these variables for a +# local release, or import them into your PaaS/secret manager. Do not commit real +# values. + +PHX_SERVER=true +PORT=4000 +POOL_SIZE=5 + +# Required identity and URL boundary. TEMPEST_HOSTNAME is a bare host only. +TEMPEST_HOSTNAME=tempest.example.com +TEMPEST_PUBLIC_URL=https://tempest.example.com +TEMPEST_DATA_DIR=/var/lib/tempest +TEMPEST_HOSTED_DID_METHOD=plc + +# Generate with: mix phx.gen.secret +SECRET_KEY_BASE=replace-with-a-generated-secret-key-base + +# Admin API/UI bearer token hash. Generate from a trusted workstation with: +# ADMIN_TOKEN='replace-with-long-random-token' mix run -e 'IO.puts Tempest.AdminAuth.hash_token(System.fetch_env!("ADMIN_TOKEN"))' +# Keep the raw ADMIN_TOKEN only in your password manager or deployment secrets. +TEMPEST_ADMIN_TOKEN_HASH=replace-with-an-argon2-admin-token-hash + +# Public relay/AppView crawlers that should learn about local repo commits. +TEMPEST_CRAWLERS=https://bsky.network,https://vsky.network + +# Local blob and backup storage. All files remain under TEMPEST_DATA_DIR. +TEMPEST_BLOB_STORE=local +TEMPEST_BLOB_MAX_BYTES=10000000 +TEMPEST_BACKUP_STORE=local + +# SMTP is optional. When disabled, password-reset/email-confirmation delivery is +# not sent through an external mail server. +TEMPEST_SMTP_ENABLED=false +# TEMPEST_SMTP_HOST=smtp.example.com +# TEMPEST_SMTP_PORT=587 +# TEMPEST_SMTP_USERNAME= +# TEMPEST_SMTP_PASSWORD= +# TEMPEST_SMTP_SSL=false +# TEMPEST_SMTP_TLS=if_available +# TEMPEST_SMTP_AUTH=if_available +# TEMPEST_SMTP_FROM_NAME=Tempest +# TEMPEST_SMTP_FROM_ADDRESS=no-reply@tempest.example.com + +# Optional S3/R2 blob storage. Set TEMPEST_BLOB_STORE=s3 to enable. +# For Cloudflare R2 use endpoint https://.r2.cloudflarestorage.com and region auto. +# TEMPEST_BLOB_S3_ENDPOINT= +# TEMPEST_BLOB_S3_BUCKET=tempest-blobs +# TEMPEST_BLOB_S3_REGION=auto +# TEMPEST_BLOB_S3_ACCESS_KEY_ID= +# TEMPEST_BLOB_S3_SECRET_ACCESS_KEY= +# TEMPEST_BLOB_CDN_BASE_URL= + +# Optional S3/R2 backup uploads. Set TEMPEST_BACKUP_STORE=s3 to enable. +# TEMPEST_BACKUP_S3_ENDPOINT= +# TEMPEST_BACKUP_S3_BUCKET=tempest-backups +# TEMPEST_BACKUP_S3_REGION=auto +# TEMPEST_BACKUP_S3_ACCESS_KEY_ID= +# TEMPEST_BACKUP_S3_SECRET_ACCESS_KEY= + +# Optional Erlang distribution/DNS clustering for hosts that support it. +# DNS_CLUSTER_QUERY= +# Generate with: openssl rand -base64 48 +# RELEASE_COOKIE=replace-with-a-generated-release-cookie + +# Optional BEAM defaults for small PaaS/container deployments. Uncomment only if +# you want to constrain Erlang distribution ports and reduce scheduler busy-wait. +# ELIXIR_ERL_OPTIONS=-kernel inet_dist_listen_min 9100 inet_dist_listen_max 9100 +sbwt none +sbwtdcpu none +sbwtdio none diff --git a/conf/Caddyfile b/conf/Caddyfile new file mode 100644 index 0000000..4408c7e --- /dev/null +++ b/conf/Caddyfile @@ -0,0 +1,20 @@ +# Caddy reverse proxy for a deployed Tempest PDS. +# +# Use with docker compose from this directory: +# cp .env.example .env +# TEMPEST_HOSTNAME=tempest.example.com docker compose -f docker-compose.yml --profile proxy up -d +# +# Caddy obtains and renews certificates automatically for public DNS names. +# WebSocket upgrades for /xrpc/com.atproto.sync.subscribeRepos are proxied by +# Caddy's reverse_proxy handler without extra directives. + +{$TEMPEST_HOSTNAME} { + encode zstd gzip + + reverse_proxy tempest:4000 { + header_up Host {host} + header_up X-Real-IP {remote_host} + header_up X-Forwarded-For {remote_host} + header_up X-Forwarded-Proto {scheme} + } +} diff --git a/conf/Dockerfile b/conf/Dockerfile new file mode 100644 index 0000000..ab29302 --- /dev/null +++ b/conf/Dockerfile @@ -0,0 +1,53 @@ +# syntax=docker/dockerfile:1 +# Build from the project root: +# docker build -f conf/Dockerfile -t tempest:local . + +ARG ELIXIR_VERSION=1.18.4 +ARG OTP_VERSION=27.3.4 +ARG ALPINE_VERSION=3.21.7 + +FROM hexpm/elixir:${ELIXIR_VERSION}-erlang-${OTP_VERSION}-alpine-${ALPINE_VERSION} AS build + +RUN apk add --no-cache build-base git npm python3 sqlite-dev + +WORKDIR /app +ENV MIX_ENV=prod + +RUN mix local.hex --force && mix local.rebar --force + +COPY mix.exs mix.lock ./ +COPY config ./config +RUN mix deps.get --only prod && mix deps.compile + +COPY assets ./assets +COPY lib ./lib +COPY priv ./priv + +RUN mix compile +RUN mix assets.deploy +RUN mix release + +FROM alpine:${ALPINE_VERSION} AS runtime + +RUN apk add --no-cache ca-certificates libgcc libstdc++ ncurses-libs openssl sqlite-libs \ + && addgroup -S tempest \ + && adduser -S -G tempest -h /app tempest \ + && mkdir -p /var/lib/tempest \ + && chown -R tempest:tempest /var/lib/tempest + +WORKDIR /app +ENV HOME=/app \ + MIX_ENV=prod \ + PHX_SERVER=true \ + PORT=4000 \ + TEMPEST_DATA_DIR=/var/lib/tempest + +COPY --from=build --chown=tempest:tempest /app/_build/prod/rel/tempest ./ +COPY --chown=tempest:tempest conf/docker-entrypoint.sh /app/bin/docker-entrypoint +RUN chmod +x /app/bin/docker-entrypoint + +USER tempest +EXPOSE 4000 +VOLUME ["/var/lib/tempest"] +ENTRYPOINT ["/app/bin/docker-entrypoint"] +CMD ["start"] diff --git a/conf/README.md b/conf/README.md new file mode 100644 index 0000000..ec1ae84 --- /dev/null +++ b/conf/README.md @@ -0,0 +1,69 @@ +# Tempest deployment config + +This directory contains runnable deployment examples for a production Phoenix +release: + +- `Dockerfile` builds a release image. +- `docker-entrypoint.sh` creates the durable storage layout, bootstraps SQLite, + runs Ecto migrations, then starts the release. +- `docker-compose.yml` runs Tempest locally, with an optional `proxy` profile for + Caddy. +- `Caddyfile` is mounted by compose as `/etc/caddy/Caddyfile` when the proxy + profile is enabled. +- `.env.example` is the single production env template for compose, releases, + and PaaS secret managers. + Copy it to `.env` and replace placeholders before running compose. + +## Docker Checks (Local) + +### Build release image + +```bash +docker build -f conf/Dockerfile -t tempest:deploy-conf-smoke . +``` + +### Validate compose config + +```bash +docker compose -f conf/docker-compose.yml config +``` + +### Validate compose config with Caddy profile + +```bash +TEMPEST_HOSTNAME=tempest.example.com \ + docker compose -f conf/docker-compose.yml --profile proxy config +``` + +### Smoke test the built container + +```bash +docker rm -f tempest-deploy-smoke >/dev/null 2>&1 || true + +SECRET_KEY_BASE=$(mix phx.gen.secret) + +docker run -d --name tempest-deploy-smoke \ + -e SECRET_KEY_BASE="$SECRET_KEY_BASE" \ + -e TEMPEST_HOSTNAME=localhost \ + -e TEMPEST_PUBLIC_URL=http://localhost:4000 \ + -e TEMPEST_DATA_DIR=/var/lib/tempest \ + -e TEMPEST_BLOB_MAX_BYTES=10000000 \ + -e TEMPEST_CRAWLERS= \ + -p 4000:4000 \ + -v tempest_deploy_smoke_data:/var/lib/tempest \ + tempest:deploy-conf-smoke +``` + +Then verify health and describeServer from outside the container: + +```bash +curl -fsS http://127.0.0.1:4000/xrpc/_health +curl -fsS http://127.0.0.1:4000/xrpc/com.atproto.server.describeServer +``` + +Cleanup: + +```bash +docker rm -f tempest-deploy-smoke +docker volume rm tempest_deploy_smoke_data +``` diff --git a/conf/docker-compose.yml b/conf/docker-compose.yml new file mode 100644 index 0000000..402d3b8 --- /dev/null +++ b/conf/docker-compose.yml @@ -0,0 +1,45 @@ +services: + tempest: + build: + context: .. + dockerfile: conf/Dockerfile + image: tempest:local + restart: unless-stopped + env_file: + - path: ./.env + required: false + environment: + PHX_SERVER: "true" + PORT: "4000" + TEMPEST_DATA_DIR: /var/lib/tempest + volumes: + - ../data/tempest:/var/lib/tempest + ports: + - "4000:4000" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:4000/xrpc/_health >/dev/null"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 20s + + caddy: + image: caddy:2-alpine + restart: unless-stopped + profiles: ["proxy"] + depends_on: + tempest: + condition: service_healthy + environment: + TEMPEST_HOSTNAME: ${TEMPEST_HOSTNAME:-tempest.example.com} + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + +volumes: + caddy_data: + caddy_config: diff --git a/conf/docker-entrypoint.sh b/conf/docker-entrypoint.sh new file mode 100644 index 0000000..ae02242 --- /dev/null +++ b/conf/docker-entrypoint.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +if [ "${1:-start}" != "start" ]; then + exec "$@" +fi + +: "${TEMPEST_DATA_DIR:=/var/lib/tempest}" +export TEMPEST_DATA_DIR + +mkdir -p \ + "$TEMPEST_DATA_DIR" \ + "$TEMPEST_DATA_DIR/repos" \ + "$TEMPEST_DATA_DIR/blobs" \ + "$TEMPEST_DATA_DIR/tmp" \ + "$TEMPEST_DATA_DIR/backups" + +if [ "${TEMPEST_RELEASE_BOOTSTRAP:-true}" != "false" ]; then + /app/bin/tempest eval ' + Tempest.Config.load!() |> Tempest.Storage.bootstrap!() + + {:ok, _repo, _apps} = + Ecto.Migrator.with_repo(Tempest.Repo, fn repo -> + Ecto.Migrator.run(repo, :up, all: true) + end) + ' +fi + +exec /app/bin/tempest start diff --git a/docs/reference/README.md b/docs/reference/README.md index c097ef5..f650696 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -1,6 +1,6 @@ --- title: Reference Documentation -updated: 2026-06-03 +updated: 2026-06-13 --- These docs describe current, verified behavior of Tempest. They explain the @@ -15,6 +15,7 @@ Graduated reference docs: - [Repository Core](./repo-core.md) - [Record APIs](./record-apis.md) - [Blobs](./blobs.md) +- [Account Migration](./account-migration.md) - [Migration and Account Lifecycle](./migration-lifecycle.md) - [Security, OAuth, and Delegated Access](./security-oauth.md) - [Admin and Operator Operations](./admin-operations.md) diff --git a/docs/reference/account-migration.md b/docs/reference/account-migration.md new file mode 100644 index 0000000..31480ff --- /dev/null +++ b/docs/reference/account-migration.md @@ -0,0 +1,171 @@ +--- +title: Account Migration +updated: 2026-06-13 +--- + +This page describes the migration path Tempest supports today and the identity +work still required for production `did:plc` account moves. + +Account migration has two separate parts: + +1. Move repository and blob data to Tempest. +2. Move identity authority so the DID document's `#atproto_pds` service points + at Tempest. + +Tempest implements the repository and lifecycle side: existing-DID account +creation with service auth, inactive imported accounts, CAR import, missing-blob +checks, activation/deactivation, and account deletion. Activation is intentionally +gated on identity correctness. Tempest must not publicly serve a migrated account +until the DID document points at this PDS. + +## Current Support + +Supported now: + +- Create an inactive account for an existing DID when `createAccount` receives a + valid service-auth token for `com.atproto.server.createAccount`. +- Import a repository CAR through `com.atproto.repo.importRepo`. +- Keep imported accounts inactive until activation. +- Report repository, record, blob, missing-blob, and `migrationReady` state + through `com.atproto.server.checkAccountStatus`. +- List missing blobs through `com.atproto.repo.listMissingBlobs`. +- Activate only after the local DID document exposes `#atproto_pds` for + `TEMPEST_PUBLIC_URL`. +- Suppress public repo, record, blob, and sync reads for inactive accounts. + +Known limitation: + +- Full `did:plc` migration is not complete. The public PLC identity endpoints + remain planned: `com.atproto.identity.getRecommendedDidCredentials`, + `requestPlcOperationSignature`, `signPlcOperation`, and + `submitPlcOperation`. + +Self-controlled `did:web` accounts are the currently practical bring-your-own +identity path because the operator can update the DID document directly. + +## Migration-In Flow + +From the source PDS, export the account repository: + +```bash +curl "$OLD_PDS/xrpc/com.atproto.sync.getRepo?did=$DID" -o repo.car +``` + +Ask the source PDS for service auth scoped to account creation on Tempest: + +```bash +curl -H "Authorization: Bearer $OLD_ACCESS" \ + "$OLD_PDS/xrpc/com.atproto.server.getServiceAuth?aud=$TEMPEST_PUBLIC_URL&lxm=com.atproto.server.createAccount" +``` + +Create the account on Tempest with the existing DID: + +```bash +curl -X POST "$TEMPEST/xrpc/com.atproto.server.createAccount" \ + -H "Content-Type: application/json" \ + -d '{ + "did": "'"$DID"'", + "handle": "'"$HANDLE"'", + "email": "'"$EMAIL"'", + "password": "'"$PASSWORD"'", + "serviceAuth": "'"$SERVICE_AUTH"'" + }' +``` + +The response should include: + +```json +{ + "did": "did:example:...", + "active": false, + "status": "deactivated" +} +``` + +Import the CAR: + +```bash +curl -X POST "$TEMPEST/xrpc/com.atproto.repo.importRepo" \ + -H "Authorization: Bearer $TEMPEST_ACCESS" \ + -H "Content-Type: application/vnd.ipld.car" \ + --data-binary @repo.car +``` + +Check for missing blobs: + +```bash +curl -H "Authorization: Bearer $TEMPEST_ACCESS" \ + "$TEMPEST/xrpc/com.atproto.repo.listMissingBlobs" +``` + +Upload any missing blobs, then check readiness: + +```bash +curl -H "Authorization: Bearer $TEMPEST_ACCESS" \ + "$TEMPEST/xrpc/com.atproto.server.checkAccountStatus" +``` + +`migrationReady` should be `true` and `missingBlobCount` should be `0` before +activation. + +Update identity so the account DID document points `#atproto_pds` at +`TEMPEST_PUBLIC_URL`. For `did:web`, update the hosted DID document. For +`did:plc`, this currently requires an external/manual PLC operation because +Tempest's public PLC operation endpoints are not implemented yet. + +Activate the account: + +```bash +curl -X POST "$TEMPEST/xrpc/com.atproto.server.activateAccount" \ + -H "Authorization: Bearer $TEMPEST_ACCESS" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +After activation, public repo, record, blob, and sync reads become available. + +## Failure Behavior + +Tempest fails closed during migration: + +- Missing or invalid service auth prevents existing-DID account creation. +- Invalid CAR data is rejected without replacing the existing repository. +- CAR commits for a different DID are rejected. +- Invalid commit signatures are rejected. +- Missing referenced blocks are rejected. +- Missing referenced blobs keep `migrationReady=false`. +- Activation fails when the DID document does not point at Tempest. + +## PLC Work Required + +Production `did:plc` migration needs the public identity operation flow, not only +repository import: + +- `com.atproto.identity.getRecommendedDidCredentials` must return the Tempest PDS + service endpoint, repository signing key, handle, and recommended rotation + keys. +- `com.atproto.identity.requestPlcOperationSignature` must create an auditable + strong-reauth challenge for PLC-sensitive operations. +- `com.atproto.identity.signPlcOperation` must reject operations that remove + recoverability or point `#atproto_pds` away from the intended service. +- `com.atproto.identity.submitPlcOperation` must submit through the PLC client, + preserve migration event ordering, and record success or failure. + +These endpoints must deny app passwords and ordinary OAuth tokens unless a future +spec defines a high-assurance delegated scope. + +## Verification + +Local lifecycle coverage: + +```bash +hurl --test --jobs 1 \ + --variable base_url=http://localhost:4000 \ + test/smoke/migration-lifecycle.hurl +``` + +Compatibility tracking: + +- [Migration and Account Lifecycle](./migration-lifecycle.md) +- [PDS Compatibility Matrix](./pds-compatibility.md) +- [Identity Troubleshooting](./identity-troubleshooting.md) diff --git a/docs/reference/deployment-observability.md b/docs/reference/deployment-observability.md index e178daa..b05180b 100644 --- a/docs/reference/deployment-observability.md +++ b/docs/reference/deployment-observability.md @@ -17,7 +17,7 @@ services: ports: - "4000:4000" env_file: - - tempest.env + - .env volumes: - ./data:/var/lib/tempest ``` diff --git a/docs/tasks/15-deployment-verification.md b/docs/tasks/15-deployment-verification.md index 438518e..704aa77 100644 --- a/docs/tasks/15-deployment-verification.md +++ b/docs/tasks/15-deployment-verification.md @@ -12,11 +12,11 @@ references: Goal: make Tempest deployable, restorable, and externally verifiable as a SQLite-first PDS on local Docker or a managed PaaS with optional S3/R2 storage. -- [ ] T15-01: Add release configuration. -- [ ] T15-02: Add Dockerfile. -- [ ] T15-03: Add docker-compose example. -- [ ] T15-04: Add Caddy reverse proxy example. -- [ ] T15-05: Add production env template. +- [x] T15-01: Add release configuration. +- [x] T15-02: Add Dockerfile. +- [x] T15-03: Add docker-compose example. +- [x] T15-04: Add Caddy reverse proxy example. +- [x] T15-05: Add production env template. - [ ] T15-06: Add deployment docs for local-only, S3-backed, and reverse-proxy setups. - [x] T15-07: Add managed PaaS deployment profile for Railway-like hosts.