diff --git a/.dockerignore b/.dockerignore --- a/.dockerignore +++ b/.dockerignore @@ -4,4 +4,5 @@ .git/ .github/ .claude/ .env +.builder-override/ docker-compose*.yml diff --git a/.env.example b/.env.example --- a/.env.example +++ b/.env.example @@ -12,6 +12,13 @@ SESSION_SECRET=change-me-in-production RELAY_URL=https://relay1.us-east.bsky.network PORT=3000 +# Cloudflare Tunnel — exposes the backend via a public HTTPS URL. +# Named tunnel (stable hostname): set CLOUDFLARE_TUNNEL_TOKEN from your +# Cloudflare dashboard and update PUBLIC_URL to match. +# Quick tunnel (random hostname): leave CLOUDFLARE_TUNNEL_TOKEN blank and +# check `docker compose logs tunnel` for the *.trycloudflare.com URL. +# CLOUDFLARE_TUNNEL_TOKEN= + # Web dashboard WEB_HOSTNAME=0.0.0.0 API_URL=http://happyview:3000 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -127,6 +127,7 @@ unit-tests: needs: changes if: always() && (needs.changes.outputs.server == 'true' || (github.event_name == 'workflow_dispatch' && inputs.server)) runs-on: depot-ubuntu-24.04 + timeout-minutes: 15 steps: - name: Checkout repository uses: actions/checkout@v6 @@ -149,6 +150,7 @@ e2e-tests: needs: changes if: always() && (needs.changes.outputs.server == 'true' || (github.event_name == 'workflow_dispatch' && inputs.server)) runs-on: depot-ubuntu-24.04 + timeout-minutes: 15 services: postgres: image: postgres:16-alpine @@ -468,8 +470,9 @@ && (needs.changes.outputs.server == 'true' || (github.event_name == 'workflow_dispatch' && inputs.server)) && needs.unit-tests.result == 'success' && needs.e2e-tests.result == 'success' && needs.frontend.result == 'success' + && needs.playwright.result == 'success' && needs.lint.result == 'success' - needs: [changes, unit-tests, e2e-tests, frontend, lint] + needs: [changes, unit-tests, e2e-tests, frontend, playwright, lint] runs-on: depot-ubuntu-24.04 outputs: version: ${{ steps.semantic.outputs.version }} diff --git a/Cargo.lock b/Cargo.lock --- a/Cargo.lock +++ b/Cargo.lock @@ -575,6 +575,15 @@ "winx", ] [[package]] +name = "cbor4ii" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b544cf8c89359205f4f990d0e6f3828db42df85b5dac95d09157a250eb0749c4" +dependencies = [ + "serde", +] + +[[package]] name = "cc" version = "1.2.55" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1662,7 +1671,9 @@ "chrono", "ciborium", "cid", "dashmap", + "data-encoding", "dotenvy", + "futures", "futures-util", "hex", "hickory-resolver", @@ -1675,11 +1686,13 @@ "mlua", "multibase", "p256", "rand 0.9.2", + "rcgen", "regex", "reqwest", "rustls", "semver", "serde", + "serde_ipld_dagcbor", "serde_json", "serial_test", "sha2", @@ -3104,6 +3117,19 @@ "crossbeam-utils", ] [[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + +[[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3500,6 +3526,18 @@ "indexmap", "itoa", "ryu", "serde_core", +] + +[[package]] +name = "serde_ipld_dagcbor" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46182f4f08349a02b45c998ba3215d3f9de826246ba02bb9dddfe9a2a2100778" +dependencies = [ + "cbor4ii", + "ipld-core", + "scopeguard", + "serde", ] [[package]] @@ -5716,6 +5754,15 @@ name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] [[package]] name = "yoke" diff --git a/Cargo.toml b/Cargo.toml --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ axum = { version = "0.8", features = ["multipart"] } axum-extra = { version = "0.10", features = ["cookie", "cookie-signed", "cookie-key-expansion", "query"] } base64 = "0.22" dashmap = "6" +data-encoding = "2" dotenvy = "0.15" hex = "0.4" futures-util = "0.3" @@ -34,6 +35,7 @@ rand = "0.9" reqwest = { version = "0.12", features = ["json"] } rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1", features = ["derive"] } +serde_ipld_dagcbor = "0.6" serde_json = "1" sha2 = "0.10" sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite", "any", "json", "chrono", "migrate"] } @@ -67,3 +69,5 @@ tower = { version = "0.5", features = ["util"] } http-body-util = "0.1" serial_test = "3" urlencoding = "2.1.3" +futures = "0.3" +rcgen = "0.13" diff --git a/docker-compose.e2e.ci.yml b/docker-compose.e2e.ci.yml new file mode 100644 --- /dev/null +++ b/docker-compose.e2e.ci.yml @@ -0,0 +1,5 @@ +services: + happyview: + build: + additional_contexts: + builder: .builder-override diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml new file mode 100644 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,91 @@ +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: happyview + POSTGRES_PASSWORD: happyview + POSTGRES_DB: happyview_test + ports: + - "5434:5432" + volumes: + - ./scripts/init-e2e-dbs.sql:/docker-entrypoint-initdb.d/init-e2e-dbs.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U happyview -d happyview_test"] + interval: 5s + timeout: 5s + retries: 10 + start_period: 5s + + plc: + build: + context: https://github.com/did-method-plc/did-method-plc.git + dockerfile: packages/server/Dockerfile + environment: + DB_CREDS_JSON: '{"username":"happyview","password":"happyview","host":"postgres","port":"5432","database":"plc"}' + ENABLE_MIGRATIONS: "true" + DB_MIGRATE_CREDS_JSON: '{"username":"happyview","password":"happyview","host":"postgres","port":"5432","database":"plc"}' + PORT: "2582" + ports: + - "2582:2582" + depends_on: + postgres: + condition: service_healthy + + pds: + image: atcr.io/tranquil.farm/tranquil-pds:latest + environment: + DATABASE_URL: postgres://happyview:happyview@postgres:5432/pds + PLC_DIRECTORY_URL: http://plc:2582 + TRANQUIL_PDS_ALLOW_INSECURE_SECRETS: "1" + volumes: + - ./scripts/e2e-config.toml:/etc/tranquil-pds/config.toml:ro + ports: + - "3100:3000" + depends_on: + postgres: + condition: service_healthy + plc: + condition: service_started + + caddy: + image: caddy:2-alpine + volumes: + - ./scripts/e2e-Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + ports: + - "443:443" + networks: + default: + aliases: + - pds.localhost + + happyview: + build: + context: . + dockerfile: Dockerfile + entrypoint: ["/bin/sh", "/e2e-entrypoint.sh"] + environment: + DATABASE_URL: postgres://happyview:happyview@postgres:5432/happyview_test + PUBLIC_URL: http://127.0.0.1:3200 + HOST: 0.0.0.0 + PORT: "3000" + PLC_URL: http://plc:2582 + RELAY_URL: http://plc:2582 + SESSION_SECRET: e2e-test-secret-that-is-at-least-32-bytes + TOKEN_ENCRYPTION_KEY: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= + JETSTREAM_URL: wss://jetstream1.us-east.bsky.network + ports: + - "3200:3000" + volumes: + - ./scripts/e2e-entrypoint.sh:/e2e-entrypoint.sh:ro + - caddy_data:/caddy-data:ro + depends_on: + postgres: + condition: service_healthy + plc: + condition: service_started + caddy: + condition: service_started + +volumes: + caddy_data: diff --git a/docker-compose.yml b/docker-compose.yml --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,8 @@ happyview: image: rust:1.93 working_dir: /app - command: cargo run + entrypoint: ["sh", "/scripts/entrypoint.sh"] + command: cargo watch -x run ports: - "3000:3000" volumes: @@ -30,17 +31,48 @@ - .:/app - cargo-registry:/usr/local/cargo/registry - cargo-git:/usr/local/cargo/git - cargo-target:/app/target + - ./scripts/entrypoint.sh:/scripts/entrypoint.sh:ro + - tunnel-url:/shared environment: DATABASE_URL: ${DATABASE_URL} PUBLIC_URL: ${PUBLIC_URL} SESSION_SECRET: ${SESSION_SECRET} RELAY_URL: ${RELAY_URL} PORT: ${PORT} + TUNNEL_URL_FILE: /shared/tunnel-url + CLOUDFLARE_TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-} # depends_on: # Uncomment if using Postgres: # postgres: # condition: service_healthy + caddy: + image: caddy:2-alpine + volumes: + - ./scripts/dev-Caddyfile:/etc/caddy/Caddyfile:ro + ports: + - "3080:80" + depends_on: + - happyview + - web + + tunnel: + image: alpine:latest + entrypoint: ["sh", "/scripts/tunnel.sh"] + volumes: + - ./scripts/tunnel.sh:/scripts/tunnel.sh:ro + - tunnel-url:/shared + environment: + CLOUDFLARE_TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:-} + TUNNEL_HOSTNAME: ${TUNNEL_HOSTNAME:-} + TUNNEL_URL_FILE: /shared/tunnel-url + TUNNEL_UPSTREAM: http://caddy:80 + depends_on: + caddy: + condition: service_started + restart: true + restart: unless-stopped + web: image: node:24-alpine working_dir: /app @@ -61,3 +93,4 @@ cargo-registry: cargo-git: cargo-target: web-node-modules: + tunnel-url: diff --git a/migrations/postgres/20260604000000_create_service_identity.sql b/migrations/postgres/20260604000000_create_service_identity.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260604000000_create_service_identity.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS service_identity ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + mode TEXT NOT NULL, + did TEXT, + signing_key_enc TEXT, + rotation_key_enc TEXT, + attached_account_did TEXT, + setup_complete BOOLEAN NOT NULL DEFAULT FALSE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); diff --git a/migrations/postgres/20260604000001_create_service_entries.sql b/migrations/postgres/20260604000001_create_service_entries.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260604000001_create_service_entries.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS service_entries ( + id SERIAL PRIMARY KEY, + fragment_id TEXT UNIQUE NOT NULL, + service_type TEXT NOT NULL, + access_mode TEXT NOT NULL DEFAULT 'all', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS service_entry_xrpcs ( + service_entry_id INTEGER NOT NULL REFERENCES service_entries(id) ON DELETE CASCADE, + lexicon_id TEXT NOT NULL, + PRIMARY KEY (service_entry_id, lexicon_id) +); diff --git a/migrations/postgres/20260604000002_add_outbound_xrpcs_to_scripts.sql b/migrations/postgres/20260604000002_add_outbound_xrpcs_to_scripts.sql new file mode 100644 --- /dev/null +++ b/migrations/postgres/20260604000002_add_outbound_xrpcs_to_scripts.sql @@ -0,0 +1,1 @@ +ALTER TABLE scripts ADD COLUMN outbound_xrpcs TEXT; diff --git a/migrations/sqlite/20260604000000_create_service_identity.sql b/migrations/sqlite/20260604000000_create_service_identity.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260604000000_create_service_identity.sql @@ -0,0 +1,11 @@ +CREATE TABLE IF NOT EXISTS service_identity ( + id INTEGER PRIMARY KEY DEFAULT 1 CHECK (id = 1), + mode TEXT NOT NULL, + did TEXT, + signing_key_enc TEXT, + rotation_key_enc TEXT, + attached_account_did TEXT, + setup_complete BOOLEAN NOT NULL DEFAULT FALSE, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/migrations/sqlite/20260604000001_create_service_entries.sql b/migrations/sqlite/20260604000001_create_service_entries.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260604000001_create_service_entries.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS service_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + fragment_id TEXT UNIQUE NOT NULL, + service_type TEXT NOT NULL, + access_mode TEXT NOT NULL DEFAULT 'all', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS service_entry_xrpcs ( + service_entry_id INTEGER NOT NULL REFERENCES service_entries(id) ON DELETE CASCADE, + lexicon_id TEXT NOT NULL, + PRIMARY KEY (service_entry_id, lexicon_id) +); diff --git a/migrations/sqlite/20260604000002_add_outbound_xrpcs_to_scripts.sql b/migrations/sqlite/20260604000002_add_outbound_xrpcs_to_scripts.sql new file mode 100644 --- /dev/null +++ b/migrations/sqlite/20260604000002_add_outbound_xrpcs_to_scripts.sql @@ -0,0 +1,1 @@ +ALTER TABLE scripts ADD COLUMN outbound_xrpcs TEXT; diff --git a/packages/docs/content/docs/getting-started/meta.json b/packages/docs/content/docs/getting-started/meta.json --- a/packages/docs/content/docs/getting-started/meta.json +++ b/packages/docs/content/docs/getting-started/meta.json @@ -3,6 +3,7 @@ "title": "Getting Started", "pages": [ "quickstart", "configuration", + "service-identity", "dashboard", "authentication", "deployment" diff --git a/packages/docs/content/docs/getting-started/service-identity.md b/packages/docs/content/docs/getting-started/service-identity.md new file mode 100644 --- /dev/null +++ b/packages/docs/content/docs/getting-started/service-identity.md @@ -0,0 +1,51 @@ +--- +title: "Service Identity" +--- + +An AT Protocol service identity lets your AppView authenticate itself to other services on the network. When a user's PDS routes a request, it verifies the destination by resolving the AppView's DID — without a service identity, standard AT Protocol app routing won't reach your instance. + +HappyView can operate without a service identity using its built-in auth, but configuring one is recommended for full network compatibility. + +## Identity modes + +HappyView supports three ways to establish a service identity during setup. + +### Domain identity (did:web) + +Your domain name becomes your identity. HappyView generates a signing keypair and serves a [DID document](https://atproto.com/specs/did#did-web) at `/.well-known/did.json` automatically. + +This is the simplest option — no external registration is needed. The identity is tied to your domain: if you change domains, you'll need to reconfigure. + +### Network identity (did:plc) + +HappyView registers a new identity in the [PLC directory](https://atproto.com/specs/did#did-plc), a public registry that maps DIDs to their metadata. This is the most durable option — the identity survives domain changes because it isn't tied to any single hostname. + +During registration, HappyView generates two keypairs: + +- **Signing key** — Used to authenticate requests from your AppView. Stored encrypted on the server and managed automatically. +- **Rotation key** — Used to recover or update the identity if the signing key is lost or the server goes down. This key is generated once and must be downloaded immediately — it cannot be retrieved later. + +Store the rotation key file somewhere safe and offline (e.g. a password manager, encrypted USB drive, or secure backup). You will need it if you ever need to migrate your identity to a new server or recover from data loss. + +### Linked account + +Link your AppView to an existing AT Protocol account you control. HappyView verifies ownership by redirecting you to sign in through that account's PDS, then uses the account's existing DID as the service identity. + +## Choosing an identity mode + +| | Domain (did:web) | Network (did:plc) | Linked account | +|---|---|---|---| +| Setup complexity | Automatic | Requires key backup | Requires existing account | +| Domain independence | No — tied to your domain | Yes — survives domain changes | Depends on the linked account | +| Key management | Automatic | You must back up the rotation key | Managed by the linked account's PDS | +| Best for | Single-domain deployments | Long-lived production instances | Operators who already have an AT Protocol presence | + +## Skipping setup + +You can skip service identity configuration during setup. Your AppView will work with HappyView's built-in authentication, but standard AT Protocol service-to-service routing won't be available. You can configure a service identity later from **Settings > Service Identity** in the dashboard. + +## Further reading + +- [AT Protocol identity specification](https://atproto.com/guides/identity) +- [DID methods in AT Protocol](https://atproto.com/specs/did) +- [AT Protocol glossary](https://atproto.com/guides/glossary) diff --git a/scripts/dev-Caddyfile b/scripts/dev-Caddyfile new file mode 100644 --- /dev/null +++ b/scripts/dev-Caddyfile @@ -0,0 +1,41 @@ +:80 { + # API, admin, auth, XRPC, and well-known routes → Rust backend + handle /api/* { + reverse_proxy happyview:3000 + } + handle /admin/* { + reverse_proxy happyview:3000 + } + handle /auth/* { + reverse_proxy happyview:3000 + } + handle /xrpc/* { + reverse_proxy happyview:3000 + } + handle /oauth/* { + reverse_proxy happyview:3000 + } + handle /external-auth/* { + reverse_proxy happyview:3000 + } + handle /.well-known/* { + reverse_proxy happyview:3000 + } + handle /oauth-client-metadata.json { + reverse_proxy happyview:3000 + } + handle /health { + reverse_proxy happyview:3000 + } + handle /config { + reverse_proxy happyview:3000 + } + handle /settings/* { + reverse_proxy happyview:3000 + } + + # Everything else → Next.js dev server + handle { + reverse_proxy web:3001 + } +} diff --git a/scripts/e2e-Caddyfile b/scripts/e2e-Caddyfile new file mode 100644 --- /dev/null +++ b/scripts/e2e-Caddyfile @@ -0,0 +1,4 @@ +pds.localhost { + tls internal + reverse_proxy pds:3000 +} diff --git a/scripts/e2e-config.toml b/scripts/e2e-config.toml new file mode 100644 --- /dev/null +++ b/scripts/e2e-config.toml @@ -0,0 +1,18 @@ +[server] +hostname = "pds.localhost" +allow_http_proxy = true +invite_code_required = false +disable_rate_limiting = true +disable_account_verification_gate = true + +[database] +url = "postgres://happyview:happyview@postgres:5432/pds" + +[storage] +path = "/var/lib/tranquil-pds/blobs" + +[plc] +directory_url = "http://plc:2582" + +[secrets] +allow_insecure = true diff --git a/scripts/e2e-entrypoint.sh b/scripts/e2e-entrypoint.sh new file mode 100644 --- /dev/null +++ b/scripts/e2e-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +# Wait for Caddy's internal CA certificate to appear in the shared volume, +# then install it so reqwest (native-tls / OpenSSL) trusts TLS connections +# proxied through Caddy (e.g. the PDS OAuth endpoints). +CA_CERT=/caddy-data/caddy/pki/authorities/local/root.crt +if [ -d /caddy-data ]; then + echo "Waiting for Caddy CA certificate..." + while [ ! -f "$CA_CERT" ]; do sleep 0.5; done + cp "$CA_CERT" /usr/local/share/ca-certificates/caddy-local.crt + update-ca-certificates 2>/dev/null + echo "Caddy CA certificate installed." +fi + +exec /entrypoint.sh diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh new file mode 100644 --- /dev/null +++ b/scripts/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +set -e + +TUNNEL_URL_FILE="${TUNNEL_URL_FILE:-}" + +if [ -n "$TUNNEL_URL_FILE" ]; then + rm -f "$TUNNEL_URL_FILE" + + echo "Waiting for tunnel URL..." + elapsed=0 + while [ ! -f "$TUNNEL_URL_FILE" ] && [ "$elapsed" -lt 30 ]; do + sleep 1 + elapsed=$((elapsed + 1)) + done + + if [ -f "$TUNNEL_URL_FILE" ]; then + url=$(cat "$TUNNEL_URL_FILE") + export PUBLIC_URL="$url" + echo "Using tunnel URL as PUBLIC_URL: $url" + else + echo "Tunnel URL not found after 30s, using PUBLIC_URL from env: $PUBLIC_URL" + fi +fi + +if ! command -v cargo-watch >/dev/null 2>&1; then + echo "Installing cargo-watch..." + cargo install cargo-watch +fi + +exec "$@" diff --git a/scripts/init-e2e-dbs.sql b/scripts/init-e2e-dbs.sql new file mode 100644 --- /dev/null +++ b/scripts/init-e2e-dbs.sql @@ -0,0 +1,2 @@ +SELECT 'CREATE DATABASE plc' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'plc')\gexec +SELECT 'CREATE DATABASE pds' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'pds')\gexec diff --git a/scripts/tunnel.sh b/scripts/tunnel.sh new file mode 100644 --- /dev/null +++ b/scripts/tunnel.sh @@ -0,0 +1,56 @@ +#!/bin/sh +set -e + +# Install cloudflared if not present +if ! command -v cloudflared >/dev/null 2>&1; then + ARCH=$(uname -m) + case "$ARCH" in + x86_64|amd64) CF_ARCH="amd64" ;; + aarch64|arm64) CF_ARCH="arm64" ;; + armv7l) CF_ARCH="arm" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; + esac + echo "Installing cloudflared ($CF_ARCH)..." + wget -q -O /usr/local/bin/cloudflared \ + "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-$CF_ARCH" + chmod +x /usr/local/bin/cloudflared +fi + +UPSTREAM="${TUNNEL_UPSTREAM:-http://happyview:3000}" +URL_FILE="${TUNNEL_URL_FILE:-/shared/tunnel-url}" + +if [ -n "$CLOUDFLARE_TUNNEL_TOKEN" ]; then + if [ -n "$TUNNEL_HOSTNAME" ]; then + mkdir -p "$(dirname "$URL_FILE")" + echo "https://$TUNNEL_HOSTNAME" > "$URL_FILE" + fi + echo "══════════════════════════════════════════════════════════════" + echo " Starting named Cloudflare tunnel" + echo " Hostname: ${TUNNEL_HOSTNAME:-}" + echo " Upstream: $UPSTREAM" + echo "══════════════════════════════════════════════════════════════" + exec cloudflared tunnel run --token "$CLOUDFLARE_TUNNEL_TOKEN" +fi + +rm -f "$URL_FILE" + +echo "══════════════════════════════════════════════════════════════" +echo " Starting quick Cloudflare tunnel" +echo " Upstream: $UPSTREAM" +echo "══════════════════════════════════════════════════════════════" + +cloudflared tunnel --url "$UPSTREAM" 2>&1 | while IFS= read -r line; do + echo "$line" + case "$line" in + *trycloudflare.com*) + url=$(echo "$line" | grep -o 'https://[a-zA-Z0-9._-]*trycloudflare\.com' | head -1) + if [ -n "$url" ]; then + mkdir -p "$(dirname "$URL_FILE")" + echo "$url" > "$URL_FILE" + echo "══════════════════════════════════════════════════════════════" + echo " Tunnel URL written to $URL_FILE" + echo "══════════════════════════════════════════════════════════════" + fi + ;; + esac +done diff --git a/src/admin/mod.rs b/src/admin/mod.rs --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -15,6 +15,8 @@ mod proxy_config; mod records; mod script_variables; mod scripts; +mod service_entries; +mod service_identity; pub mod settings; mod stats; pub(crate) mod types; @@ -30,6 +32,10 @@ Router::new() .route( "/lexicons", post(lexicons::upload_lexicon).get(lexicons::list_lexicons), + ) + .route( + "/lexicons/{id}/services", + get(service_entries::lexicon_services), ) .route( "/lexicons/{id}", @@ -157,4 +163,31 @@ .route("/dead-letters/{id}/dismiss", post(dead_letters::dismiss)) .route("/dead-letters/{id}/retry", post(dead_letters::retry)) .route("/dead-letters/{id}/reindex", post(dead_letters::reindex)) .route("/permissions", get(users::list_permissions)) + .route( + "/service-identity", + get(service_identity::get).put(service_identity::update), + ) + .route( + "/service-entries", + get(service_entries::list).post(service_entries::create), + ) + .route("/service-entries/sync-plc", post(service_entries::sync_plc)) + .route( + "/service-entries/sync-plc/request", + post(service_entries::sync_plc_request), + ) + .route( + "/service-entries/sync-plc/submit", + post(service_entries::sync_plc_submit), + ) + .route( + "/service-entries/{id}", + put(service_entries::update).delete(service_entries::delete), + ) + .route( + "/service-entries/{id}/xrpcs", + get(service_entries::list_xrpcs) + .post(service_entries::add_xrpcs) + .delete(service_entries::remove_xrpcs), + ) } diff --git a/src/admin/scripts.rs b/src/admin/scripts.rs --- a/src/admin/scripts.rs +++ b/src/admin/scripts.rs @@ -45,6 +45,7 @@ pub id: String, pub script_type: String, pub body: String, pub description: Option, + pub outbound_xrpcs: Option>, pub created_at: String, pub updated_at: String, } @@ -105,7 +106,7 @@ auth.require(Permission::ScriptsRead).await?; let backend = state.db_backend; let mut sql = String::from( - "SELECT id, script_type, body, description, created_at, updated_at + "SELECT id, script_type, body, description, outbound_xrpcs, created_at, updated_at FROM scripts", ); if query.suffix.is_some() { @@ -115,7 +116,18 @@ sql.push_str(" ORDER BY id"); let sql = adapt_sql(&sql, backend); #[allow(clippy::type_complexity)] - let mut q = sqlx::query_as::<_, (String, String, String, Option, String, String)>(&sql); + let mut q = sqlx::query_as::< + _, + ( + String, + String, + String, + Option, + Option, + String, + String, + ), + >(&sql); if let Some(ref suffix) = query.suffix { q = q.bind(format!("%:{suffix}")); } @@ -127,13 +139,18 @@ let scripts: Vec = rows .into_iter() .map( - |(id, script_type, body, description, created_at, updated_at)| ScriptResponse { - id, - script_type, - body, - description, - created_at, - updated_at, + |(id, script_type, body, description, outbound_xrpcs_json, created_at, updated_at)| { + let outbound_xrpcs: Option> = + outbound_xrpcs_json.and_then(|j| serde_json::from_str(&j).ok()); + ScriptResponse { + id, + script_type, + body, + description, + outbound_xrpcs, + created_at, + updated_at, + } }, ) .collect(); @@ -175,6 +192,16 @@ let script_type = body.script_type.unwrap_or_default(); validate_body_for_type(&body.body, script_type)?; + let outbound_xrpcs = crate::lua_analysis::extract_outbound_xrpcs(&body.body); + let outbound_json = + if outbound_xrpcs.is_empty() { + None + } else { + Some(serde_json::to_string(&outbound_xrpcs).map_err(|e| { + AppError::Internal(format!("failed to serialize outbound xrpcs: {e}")) + })?) + }; + let backend = state.db_backend; let now = now_rfc3339(); let description = body.description.as_deref().filter(|s| !s.is_empty()); @@ -190,13 +217,14 @@ let was_new = pre_exists.is_none(); let sql = adapt_sql( r#" - INSERT INTO scripts (id, script_type, body, description, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO scripts (id, script_type, body, description, outbound_xrpcs, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET - script_type = EXCLUDED.script_type, - body = EXCLUDED.body, - description = EXCLUDED.description, - updated_at = EXCLUDED.updated_at + script_type = EXCLUDED.script_type, + body = EXCLUDED.body, + description = EXCLUDED.description, + outbound_xrpcs = EXCLUDED.outbound_xrpcs, + updated_at = EXCLUDED.updated_at "#, backend, ); @@ -205,6 +233,7 @@ .bind(&body.id) .bind(script_type.as_str()) .bind(&body.body) .bind(description) + .bind(&outbound_json) .bind(&now) .bind(&now) .execute(&state.db) @@ -290,13 +319,24 @@ Some(desc_opt) => desc_opt, None => existing.description, }; + let outbound_xrpcs = crate::lua_analysis::extract_outbound_xrpcs(&new_body); + let outbound_json: Option = + if outbound_xrpcs.is_empty() { + None + } else { + Some(serde_json::to_string(&outbound_xrpcs).map_err(|e| { + AppError::Internal(format!("failed to serialize outbound xrpcs: {e}")) + })?) + }; + let sql = adapt_sql( r#" UPDATE scripts - SET script_type = ?, - body = ?, - description = ?, - updated_at = ? + SET script_type = ?, + body = ?, + description = ?, + outbound_xrpcs = ?, + updated_at = ? WHERE id = ? "#, backend, @@ -305,6 +345,7 @@ sqlx::query(&sql) .bind(&new_script_type) .bind(&new_body) .bind(new_description.as_deref()) + .bind(&outbound_json) .bind(&now) .bind(&id) .execute(&state.db) @@ -373,24 +414,34 @@ /// Look up a single script row; 404 if missing. async fn fetch_one(state: &AppState, id: &str) -> Result { let backend = state.db_backend; let sql = adapt_sql( - "SELECT id, script_type, body, description, created_at, updated_at + "SELECT id, script_type, body, description, outbound_xrpcs, created_at, updated_at FROM scripts WHERE id = ?", backend, ); #[allow(clippy::type_complexity)] - let row: Option<(String, String, String, Option, String, String)> = - sqlx::query_as(&sql) - .bind(id) - .fetch_optional(&state.db) - .await - .map_err(|e| AppError::Internal(format!("failed to fetch script: {e}")))?; - let (id, script_type, body, description, created_at, updated_at) = + let row: Option<( + String, + String, + String, + Option, + Option, + String, + String, + )> = sqlx::query_as(&sql) + .bind(id) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch script: {e}")))?; + let (id, script_type, body, description, outbound_xrpcs_json, created_at, updated_at) = row.ok_or_else(|| AppError::NotFound(format!("script '{id}' not found")))?; + let outbound_xrpcs: Option> = + outbound_xrpcs_json.and_then(|j| serde_json::from_str(&j).ok()); Ok(ScriptResponse { id, script_type, body, description, + outbound_xrpcs, created_at, updated_at, }) diff --git a/src/admin/service_entries.rs b/src/admin/service_entries.rs new file mode 100644 --- /dev/null +++ b/src/admin/service_entries.rs @@ -0,0 +1,521 @@ +use atrium_api::agent::Agent; +use atrium_api::types::Unknown; +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; + +use crate::AppState; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::service_entries::{ + CreateServiceEntry, ServiceEntry, UpdateServiceEntry, add_entry_xrpcs, create_entry, + delete_entry, list_entries, list_entry_xrpcs, remove_entry_xrpcs, services_for_lexicon, + update_entry, +}; +use crate::service_identity::IdentityMode; + +use super::auth::UserAuth; +use super::permissions::Permission; + +fn is_pds_session_expired(err: &impl std::fmt::Display) -> bool { + let msg = err.to_string(); + msg.contains("invalid_token") || msg.contains("expired") || msg.contains("revoked") +} + +fn pds_reauth_error() -> AppError { + AppError::Auth( + "Your PDS session has expired or been revoked. \ + Use the Re-authenticate button on the Service Identity page to sign in again." + .into(), + ) +} + +/// GET /admin/service-entries — list all service entries. +pub(super) async fn list( + State(state): State, + auth: UserAuth, +) -> Result>, AppError> { + auth.require(Permission::SettingsManage).await?; + + let entries = list_entries(&state.db, state.db_backend).await?; + Ok(Json(entries)) +} + +/// POST /admin/service-entries — create a new service entry. +pub(super) async fn create( + State(state): State, + auth: UserAuth, + Json(body): Json, +) -> Result<(StatusCode, Json), AppError> { + auth.require(Permission::SettingsManage).await?; + + let entry = create_entry(&state.db, state.db_backend, &body).await?; + Ok((StatusCode::CREATED, Json(entry))) +} + +/// PUT /admin/service-entries/{id} — update a service entry. +pub(super) async fn update( + State(state): State, + auth: UserAuth, + Path(id): Path, + Json(body): Json, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + update_entry(&state.db, state.db_backend, id, &body).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// DELETE /admin/service-entries/{id} — delete a service entry. +pub(super) async fn delete( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let deleted = delete_entry(&state.db, state.db_backend, id).await?; + if !deleted { + return Err(AppError::NotFound(format!("service entry {id} not found"))); + } + + log_event( + &state.db, + EventLog { + event_type: "service_entry.deleted".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: Some(id.to_string()), + detail: serde_json::json!({}), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +/// GET /admin/service-entries/{id}/xrpcs — list lexicon IDs for a service entry. +pub(super) async fn list_xrpcs( + State(state): State, + auth: UserAuth, + Path(id): Path, +) -> Result>, AppError> { + auth.require(Permission::SettingsManage).await?; + + let xrpcs = list_entry_xrpcs(&state.db, state.db_backend, id).await?; + Ok(Json(xrpcs)) +} + +#[derive(Debug, serde::Deserialize)] +pub(super) struct XrpcListBody { + pub lexicon_ids: Vec, +} + +/// POST /admin/service-entries/{id}/xrpcs — add lexicon IDs to a service entry. +pub(super) async fn add_xrpcs( + State(state): State, + auth: UserAuth, + Path(id): Path, + Json(body): Json, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + add_entry_xrpcs(&state.db, state.db_backend, id, &body.lexicon_ids).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// DELETE /admin/service-entries/{id}/xrpcs — remove lexicon IDs from a service entry. +pub(super) async fn remove_xrpcs( + State(state): State, + auth: UserAuth, + Path(id): Path, + Json(body): Json, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + remove_entry_xrpcs(&state.db, state.db_backend, id, &body.lexicon_ids).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// GET /admin/lexicons/{id}/services — list service entries that grant access to a lexicon. +pub(super) async fn lexicon_services( + State(state): State, + auth: UserAuth, + Path(lexicon_id): Path, +) -> Result>, AppError> { + auth.require(Permission::SettingsManage).await?; + + let entries = services_for_lexicon(&state.db, state.db_backend, &lexicon_id).await?; + Ok(Json(entries)) +} + +// --------------------------------------------------------------------------- +// PLC sync endpoints +// --------------------------------------------------------------------------- + +/// POST /admin/service-entries/sync-plc — one-click PLC sync for did_plc mode. +/// +/// Signs and submits a PLC update operation directly using the rotation key. +pub(super) async fn sync_plc( + State(state): State, + auth: UserAuth, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let identity = crate::service_identity::get_identity(&state.db, state.db_backend).await?; + let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; + + if identity.mode != IdentityMode::DidPlc { + return Err(AppError::BadRequest( + "PLC sync only supported for did_plc mode".into(), + )); + } + + let did = identity + .did + .as_ref() + .ok_or_else(|| AppError::BadRequest("no DID registered yet".into()))?; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + // Fetch last PLC operation to get prev CID and preserve existing fields + let plc_url = &state.config.plc_url; + let last_op = crate::plc::fetch_last_operation(&state.http, plc_url, did).await?; + let prev_cid = crate::plc::extract_prev_cid(&last_op)?; + + // Preserve existing fields from the current DID document + let rotation_keys: Vec = last_op["rotationKeys"] + .as_array() + .ok_or_else(|| AppError::Internal("no rotationKeys in PLC operation".into()))? + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + let also_known_as: Vec = last_op["alsoKnownAs"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + let verification_methods = last_op["verificationMethods"] + .as_object() + .cloned() + .unwrap_or_default(); + + // Build services: start from existing, then merge our service entries + let mut services_map = last_op["services"].as_object().cloned().unwrap_or_default(); + + let entries = list_entries(&state.db, state.db_backend).await?; + let public_url = &state.config.public_url; + + // Collect the fragment keys we manage so we can remove stale entries + let managed_keys: std::collections::HashSet = entries + .iter() + .map(|e| e.fragment_id.trim_start_matches('#').to_string()) + .collect(); + + // Remove any services that were previously managed but are no longer present + // (We only remove keys that look like they could be ours — those that were in + // the DB before. We detect "ours" by checking endpoint == public_url.) + services_map.retain(|key, val| { + if managed_keys.contains(key) { + return true; // will be overwritten below + } + // Keep services whose endpoint differs from ours (they belong to the account) + val["endpoint"].as_str() != Some(public_url) + }); + + for entry in &entries { + let key = entry.fragment_id.trim_start_matches('#').to_string(); + services_map.insert( + key, + serde_json::json!({ + "type": entry.service_type, + "endpoint": public_url, + }), + ); + } + + // Build, sign, and submit the update operation + let unsigned = crate::plc::build_update_operation( + &prev_cid, + rotation_keys, + verification_methods, + also_known_as, + services_map, + ); + + // Decrypt the rotation key for signing + let rotation_key_enc_sql = crate::db::adapt_sql( + "SELECT rotation_key_enc FROM service_identity WHERE id = 1", + state.db_backend, + ); + let row: Option<(Option,)> = sqlx::query_as(&rotation_key_enc_sql) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch rotation key: {e}")))?; + let rotation_key_enc = row + .and_then(|(k,)| k) + .ok_or_else(|| AppError::Internal("no rotation key stored".into()))?; + let rotation_key_bytes = crate::plc::decrypt_key(&rotation_key_enc, encryption_key)?; + let rotation_signing_key = + p256::ecdsa::SigningKey::from_bytes(rotation_key_bytes.as_slice().into()) + .map_err(|e| AppError::Internal(format!("invalid rotation key: {e}")))?; + + let signed = crate::plc::sign_operation(&unsigned, &rotation_signing_key)?; + crate::plc::submit_operation(&state.http, plc_url, did, &signed).await?; + + log_event( + &state.db, + EventLog { + event_type: "service_entry.plc_synced".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: None, + detail: serde_json::json!({ "mode": "did_plc" }), + }, + state.db_backend, + ) + .await; + + tracing::info!(did = %did, "PLC DID document synced (did_plc mode)"); + + Ok(StatusCode::NO_CONTENT) +} + +/// POST /admin/service-entries/sync-plc/request — request PLC operation signature +/// for attach_account mode (sends email confirmation code). +pub(super) async fn sync_plc_request( + State(state): State, + auth: UserAuth, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let identity = crate::service_identity::get_identity(&state.db, state.db_backend).await?; + let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; + + let account_did = match identity.mode { + IdentityMode::AttachAccount => { + let sql = crate::db::adapt_sql( + "SELECT attached_account_did FROM service_identity WHERE id = 1", + state.db_backend, + ); + let row: Option<(Option,)> = sqlx::query_as(&sql) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch identity: {e}")))?; + row.and_then(|(did,)| did) + .ok_or_else(|| AppError::BadRequest("no attached account DID configured".into()))? + } + _ => { + return Err(AppError::BadRequest( + "PLC sync request only supported for attach_account mode".into(), + )); + } + }; + + let session = crate::repo::session::get_oauth_session(&state, &account_did) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + e + })?; + let agent = Agent::new(session); + + agent + .api + .com + .atproto + .identity + .request_plc_operation_signature() + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("requestPlcOperationSignature failed: {e}")) + })?; + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, serde::Deserialize)] +pub(super) struct SyncPlcSubmitBody { + token: String, +} + +/// POST /admin/service-entries/sync-plc/submit — submit PLC operation with email token +/// for attach_account mode. +pub(super) async fn sync_plc_submit( + State(state): State, + auth: UserAuth, + Json(body): Json, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let identity = crate::service_identity::get_identity(&state.db, state.db_backend).await?; + let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; + + let account_did = match identity.mode { + IdentityMode::AttachAccount => { + let sql = crate::db::adapt_sql( + "SELECT attached_account_did FROM service_identity WHERE id = 1", + state.db_backend, + ); + let row: Option<(Option,)> = sqlx::query_as(&sql) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch identity: {e}")))?; + row.and_then(|(did,)| did) + .ok_or_else(|| AppError::BadRequest("no attached account DID configured".into()))? + } + _ => { + return Err(AppError::BadRequest( + "PLC sync submit only supported for attach_account mode".into(), + )); + } + }; + + let session = crate::repo::session::get_oauth_session(&state, &account_did) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + e + })?; + let agent = Agent::new(session); + + // Fetch current PLC operation state + let plc_url = state.config.plc_url.trim_end_matches('/'); + let last_op = crate::plc::fetch_last_operation(&state.http, plc_url, &account_did).await?; + + // Preserve existing fields + let rotation_keys: Vec = last_op["rotationKeys"] + .as_array() + .ok_or_else(|| AppError::Internal("no rotationKeys in PLC operation".into()))? + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + let also_known_as: Vec = last_op["alsoKnownAs"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + // Build services: merge existing + add our service entries + let mut services_map = last_op["services"].as_object().cloned().unwrap_or_default(); + + let entries = list_entries(&state.db, state.db_backend).await?; + let public_url = &state.config.public_url; + + // Remove services whose endpoint matches ours that are no longer in the DB + let managed_keys: std::collections::HashSet = entries + .iter() + .map(|e| e.fragment_id.trim_start_matches('#').to_string()) + .collect(); + + services_map.retain(|key, val| { + if managed_keys.contains(key) { + return true; + } + val["endpoint"].as_str() != Some(public_url) + }); + + for entry in &entries { + let key = entry.fragment_id.trim_start_matches('#').to_string(); + services_map.insert( + key, + serde_json::json!({ + "type": entry.service_type, + "endpoint": public_url, + }), + ); + } + + let services: Unknown = serde_json::from_value(serde_json::Value::Object(services_map)) + .map_err(|e| AppError::Internal(format!("failed to build services Unknown: {e}")))?; + + // Preserve existing verification methods + let vm_map = last_op["verificationMethods"] + .as_object() + .cloned() + .unwrap_or_default(); + let verification_methods: Unknown = serde_json::from_value(serde_json::Value::Object(vm_map)) + .map_err(|e| { + AppError::Internal(format!("failed to build verification methods Unknown: {e}")) + })?; + + // Sign the PLC operation via the user's PDS + use atrium_api::com::atproto::identity::sign_plc_operation; + let sign_result = agent + .api + .com + .atproto + .identity + .sign_plc_operation( + sign_plc_operation::InputData { + token: Some(body.token), + services: Some(services), + verification_methods: Some(verification_methods), + also_known_as: Some(also_known_as), + rotation_keys: Some(rotation_keys), + } + .into(), + ) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("signPlcOperation failed: {e}")) + })?; + + // Submit the signed operation + use atrium_api::com::atproto::identity::submit_plc_operation; + agent + .api + .com + .atproto + .identity + .submit_plc_operation( + submit_plc_operation::InputData { + operation: sign_result.operation.clone(), + } + .into(), + ) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("submitPlcOperation failed: {e}")) + })?; + + log_event( + &state.db, + EventLog { + event_type: "service_entry.plc_synced".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: None, + detail: serde_json::json!({ "mode": "attach_account" }), + }, + state.db_backend, + ) + .await; + + tracing::info!(did = %account_did, "PLC DID document synced (attach_account mode)"); + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/admin/service_identity.rs b/src/admin/service_identity.rs new file mode 100644 --- /dev/null +++ b/src/admin/service_identity.rs @@ -0,0 +1,72 @@ +use axum::{Json, extract::State, http::StatusCode}; + +use crate::AppState; +use crate::error::AppError; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::service_identity::{IdentityMode, get_identity, upsert_identity}; + +use super::auth::UserAuth; +use super::permissions::Permission; + +/// GET /admin/service-identity — return current identity config (or null). +pub(super) async fn get( + State(state): State, + auth: UserAuth, +) -> Result, AppError> { + auth.require(Permission::SettingsManage).await?; + + let identity = get_identity(&state.db, state.db_backend).await?; + + Ok(Json(match identity { + Some(id) => serde_json::to_value(id) + .map_err(|e| AppError::Internal(format!("failed to serialize identity: {e}")))?, + None => serde_json::Value::Null, + })) +} + +#[derive(Debug, serde::Deserialize)] +pub(super) struct UpdateIdentityBody { + pub mode: String, + pub did: Option, + pub signing_key_enc: Option, + pub rotation_key_enc: Option, + pub attached_account_did: Option, +} + +/// PUT /admin/service-identity — update identity config. +pub(super) async fn update( + State(state): State, + auth: UserAuth, + Json(body): Json, +) -> Result { + auth.require(Permission::SettingsManage).await?; + + let mode = IdentityMode::parse(&body.mode) + .ok_or_else(|| AppError::BadRequest(format!("invalid identity mode: {}", body.mode)))?; + + upsert_identity( + &state.db, + state.db_backend, + &mode, + body.did.as_deref(), + body.signing_key_enc.as_deref(), + body.rotation_key_enc.as_deref(), + body.attached_account_did.as_deref(), + ) + .await?; + + log_event( + &state.db, + EventLog { + event_type: "service_identity.updated".to_string(), + severity: Severity::Info, + actor_did: Some(auth.did.clone()), + subject: None, + detail: serde_json::json!({ "mode": body.mode }), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs --- a/src/auth/middleware.rs +++ b/src/auth/middleware.rs @@ -55,6 +55,15 @@ #[cfg(test)] pub fn new_for_test(did: String) -> Self { Self::internal(did) } + + #[cfg(test)] + pub fn with_client_key(did: String, client_key: String) -> Self { + Self { + did, + client_key: Some(client_key), + dpop_key_id: None, + } + } } impl FromRequestParts for Claims { @@ -229,13 +238,21 @@ } /// XRPC-specific claims extractor. /// -/// Accepts DPoP auth (`Authorization: DPoP `) or Bearer space credential -/// JWTs (`Authorization: Bearer `). Cookie auth, Bearer API keys, -/// and service JWTs are rejected on XRPC routes. +/// Accepts DPoP auth (`Authorization: DPoP `), Bearer space credential +/// JWTs (`Authorization: Bearer `), or Bearer service auth +/// JWTs (`Authorization: Bearer `). Cookie auth and Bearer API keys +/// are rejected on XRPC routes. #[derive(Debug, Clone)] pub struct XrpcClaims { pub identity: Option, pub space_credential: Option, + pub service_auth: Option, +} + +#[derive(Debug, Clone)] +pub struct ServiceAuthClaims { + pub did: String, + pub aud_fragment: String, } impl FromRequestParts for XrpcClaims { @@ -257,17 +274,34 @@ let claims = resolve_dpop_claims(state, parts, token).await?; Ok(XrpcClaims { identity: Some(claims), space_credential: None, + service_auth: None, }) } Some(h) if h.starts_with("Bearer ") => { let token = &h[7..]; let path = parts.uri.path(); let is_space_route = path.contains("/dev.happyview.space."); + + // Try service auth first + let host = parts + .headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()); + if let Ok(service_claims) = try_parse_service_auth(token, state, host).await { + return Ok(XrpcClaims { + identity: None, + space_credential: None, + service_auth: Some(service_claims), + }); + } + + // Existing space credential logic match crate::spaces::credential::peek_jwt_typ(token) { Some(typ) if typ == "space_credential" && is_space_route => { Ok(XrpcClaims { identity: None, space_credential: Some(token.to_string()), + service_auth: None, }) } Some(typ) if typ == "space_credential" => Err(AppError::Auth( @@ -284,8 +318,68 @@ // No auth header — anonymous access (client-key only) Ok(XrpcClaims { identity: None, space_credential: None, + service_auth: None, }) } } } } + +async fn try_parse_service_auth( + token: &str, + state: &AppState, + host: Option<&str>, +) -> Result { + // 1. Check if service identity is configured and not "not_exposed" + let identity = crate::service_identity::get_identity(&state.db, state.db_backend).await?; + let identity = + identity.ok_or_else(|| AppError::Auth("no service identity configured".into()))?; + + if identity.mode == crate::service_identity::IdentityMode::NotExposed { + return Err(AppError::Auth("service auth disabled".into())); + } + + let instance_did = match &identity.mode { + crate::service_identity::IdentityMode::DidWeb => { + let h = host.ok_or_else(|| AppError::Auth("missing Host header for did:web".into()))?; + format!("did:web:{}", h.replace(':', "%3A")) + } + _ => identity + .did + .clone() + .ok_or_else(|| AppError::Auth("no DID configured".into()))?, + }; + + // 2. Verify the JWT (this resolves the issuer's DID doc and checks signature) + let service_auth = crate::auth::service_auth::ServiceAuth::from_bearer(token, state) + .await + .map_err(|_| AppError::Auth("invalid service auth token".into()))?; + + // 3. Decode payload to extract aud + let payload = crate::auth::service_auth::decode_jwt_payload(token) + .map_err(|_| AppError::Auth("failed to decode JWT".into()))?; + + let aud = payload + .aud + .ok_or_else(|| AppError::Auth("JWT missing aud field".into()))?; + + // 4. Verify aud starts with instance DID and extract fragment + if !aud.starts_with(&*instance_did) { + return Err(AppError::Auth(format!( + "JWT aud '{}' does not match instance DID '{}'", + aud, instance_did + ))); + } + + let fragment = aud.strip_prefix(&*instance_did).unwrap_or("").to_string(); + if fragment.is_empty() || !fragment.starts_with('#') { + return Err(AppError::Auth( + "JWT aud must include a service fragment".into(), + )); + } + + Ok(ServiceAuthClaims { + did: service_auth.did, + aud_fragment: fragment, + }) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -6,6 +6,7 @@ pub mod service_auth; pub use client_registry::OAuthClientRegistry; pub use middleware::Claims; +pub use middleware::ServiceAuthClaims; pub use middleware::XrpcClaims; pub use routes::parse_scope_string; pub use service_auth::ServiceAuth; diff --git a/src/auth/routes.rs b/src/auth/routes.rs --- a/src/auth/routes.rs +++ b/src/auth/routes.rs @@ -16,6 +16,10 @@ /// Legacy cookie name from the old cookie-based redirect approach. /// Detected and removed in the callback to clean up stale cookies. const LEGACY_REDIRECT_COOKIE: &str = "happyview_redirect"; +fn is_https(public_url: &str) -> bool { + public_url.starts_with("https://") +} + #[derive(Deserialize)] pub struct LoginQuery { handle: String, @@ -212,6 +216,7 @@ .ok_or_else(|| AppError::Internal("no DID in OAuth session".into()))?; // Check if the user is authorized to access the dashboard. // Allow login when no users exist yet (first user will be bootstrapped as admin). + // Also allow login for the configured attached account DID (setup attach-auth flow). // Otherwise, only allow users already in the users table. let user_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") .fetch_one(&state.db) @@ -229,13 +234,25 @@ .await .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; if user_exists.is_none() { - let login_url = state - .config - .base_path - .as_ref() - .map(|bp| format!("{}/login?error=not_authorized", bp)) - .unwrap_or_else(|| "/login?error=not_authorized".into()); - return Ok((jar, Redirect::to(&login_url))); + // Allow login if this DID is the configured attached account (setup flow) + let is_attached_account: Option<(i32,)> = sqlx::query_as(&adapt_sql( + "SELECT 1 FROM service_identity WHERE attached_account_did = ?", + state.db_backend, + )) + .bind(did.as_ref()) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + if is_attached_account.is_none() { + let login_url = state + .config + .base_path + .as_ref() + .map(|bp| format!("{}/login?error=not_authorized", bp)) + .unwrap_or_else(|| "/login?error=not_authorized".into()); + return Ok((jar, Redirect::to(&login_url))); + } } } @@ -274,18 +291,24 @@ format!("{did_str}\n{ck}") } else { did_str.to_string() }; + let secure = is_https(&state.config.public_url); + let same_site = if secure { + axum_extra::extract::cookie::SameSite::None + } else { + axum_extra::extract::cookie::SameSite::Lax + }; let mut session_cookie = Cookie::new(COOKIE_NAME, cookie_value); session_cookie.set_path("/"); session_cookie.set_http_only(true); - session_cookie.set_same_site(axum_extra::extract::cookie::SameSite::None); - session_cookie.set_secure(true); // Required when SameSite=None + session_cookie.set_same_site(same_site); + session_cookie.set_secure(secure); // Remove the legacy redirect cookie if present (old cookie-based approach) let jar = if jar.get(LEGACY_REDIRECT_COOKIE).is_some() { let mut removal = Cookie::from(LEGACY_REDIRECT_COOKIE); removal.set_path("/"); - removal.set_same_site(axum_extra::extract::cookie::SameSite::None); - removal.set_secure(true); + removal.set_same_site(same_site); + removal.set_secure(secure); jar.add(session_cookie).remove(removal) } else { jar.add(session_cookie) @@ -306,10 +329,16 @@ let _ = state.oauth.primary_client().revoke(&did).await; } } + let secure = is_https(&state.config.public_url); + let same_site = if secure { + axum_extra::extract::cookie::SameSite::None + } else { + axum_extra::extract::cookie::SameSite::Lax + }; let mut removal = Cookie::from(COOKIE_NAME); removal.set_path("/"); - removal.set_same_site(axum_extra::extract::cookie::SameSite::None); - removal.set_secure(true); + removal.set_same_site(same_site); + removal.set_secure(secure); let jar = jar.remove(removal); Ok(jar) } diff --git a/src/auth/service_auth.rs b/src/auth/service_auth.rs --- a/src/auth/service_auth.rs +++ b/src/auth/service_auth.rs @@ -171,9 +171,16 @@ async fn resolve_signing_key(did: &str, state: &AppState) -> Result, AppError> { let url = if did.starts_with("did:plc:") { format!("{}/{did}", state.config.plc_url.trim_end_matches('/')) } else if did.starts_with("did:web:") { - let domain = did.strip_prefix("did:web:").unwrap(); - let domain = domain.replace(':', "/"); - format!("https://{domain}/.well-known/did.json") + let identifier = did.strip_prefix("did:web:").unwrap(); + let mut segments = identifier.split(':'); + let host = segments.next().unwrap(); + let host = urlencoding::decode(host).unwrap_or_else(|_| host.into()); + let path_segments: Vec<&str> = segments.collect(); + if path_segments.is_empty() { + format!("https://{host}/.well-known/did.json") + } else { + format!("https://{host}/{}/did.json", path_segments.join("/")) + } } else { return Err(AppError::BadRequest(format!( "unsupported DID method: {did}" @@ -258,6 +265,30 @@ false } +/// Minimal JWT payload for extracting fields after signature verification. +#[derive(Debug, serde::Deserialize)] +pub struct PublicJwtPayload { + pub iss: String, + pub aud: Option, + pub exp: u64, +} + +/// Decode the JWT payload without verification. +/// +/// Call this *after* `ServiceAuth::from_bearer` has already validated the +/// signature. This is used to extract the `aud` field for service auth +/// fragment matching. +pub fn decode_jwt_payload(token: &str) -> Result { + let parts: Vec<&str> = token.split('.').collect(); + if parts.len() != 3 { + return Err(AppError::Auth("invalid JWT format".into())); + } + let payload_bytes = URL_SAFE_NO_PAD + .decode(parts[1]) + .map_err(|_| AppError::Auth("invalid JWT payload encoding".into()))?; + serde_json::from_slice(&payload_bytes).map_err(|_| AppError::Auth("invalid JWT payload".into())) +} + fn verify_es256k(msg: &[u8], sig_bytes: &[u8], key_bytes: &[u8]) -> bool { use k256::ecdsa::{Signature as K256Signature, VerifyingKey as K256Key, signature::Verifier}; @@ -281,3 +312,127 @@ } false } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_test_jwt(payload_json: &str) -> String { + let header = base64::engine::general_purpose::URL_SAFE_NO_PAD + .encode(r#"{"alg":"ES256","typ":"JWT"}"#); + let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json); + let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("fake_sig"); + format!("{}.{}.{}", header, payload, signature) + } + + #[test] + fn decode_valid_payload() { + let jwt = make_test_jwt( + r#"{"iss":"did:plc:abc","aud":"did:web:example.com#svc","exp":9999999999}"#, + ); + let payload = decode_jwt_payload(&jwt).unwrap(); + assert_eq!(payload.iss, "did:plc:abc"); + assert_eq!(payload.aud.unwrap(), "did:web:example.com#svc"); + assert_eq!(payload.exp, 9999999999); + } + + #[test] + fn decode_payload_without_aud() { + let jwt = make_test_jwt(r#"{"iss":"did:plc:abc","exp":9999999999}"#); + let payload = decode_jwt_payload(&jwt).unwrap(); + assert!(payload.aud.is_none()); + } + + #[test] + fn decode_rejects_invalid_format() { + assert!(decode_jwt_payload("not.a.valid.jwt.with.too.many.parts").is_err()); + assert!(decode_jwt_payload("onlyonepart").is_err()); + assert!(decode_jwt_payload("two.parts").is_err()); + } + + #[test] + fn decode_jwt_payload_rejects_not_three_parts() { + assert!(decode_jwt_payload("notenoughparts").is_err()); + assert!(decode_jwt_payload("two.parts").is_err()); + assert!(decode_jwt_payload("a.b.c.d").is_err()); + } + + #[test] + fn decode_jwt_payload_rejects_invalid_base64() { + let jwt = "validheader.!!!invalid-base64!!!.sig"; + let result = decode_jwt_payload(jwt); + assert!(result.is_err()); + } + + #[test] + fn verify_es256_rejects_invalid_key_bytes() { + assert!(!verify_es256(b"test message", &[0u8; 64], &[0xFF; 5])); + } + + #[test] + fn verify_es256k_rejects_invalid_key_bytes() { + assert!(!verify_es256k(b"test message", &[0u8; 64], &[0xFF; 5])); + } + + #[test] + fn decode_multibase_key_rejects_invalid_multibase() { + let result = decode_multibase_key("not-a-valid-multibase-string!!!", "Multikey"); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("multibase"), + "error should mention multibase: {msg}" + ); + } + + #[test] + fn decode_multibase_key_secp256r1_returns_raw_bytes() { + let raw_bytes = vec![0x04, 0xAA, 0xBB, 0xCC, 0xDD]; + let encoded = multibase::encode(multibase::Base::Base58Btc, &raw_bytes); + let result = decode_multibase_key(&encoded, "EcdsaSecp256r1VerificationKey2019").unwrap(); + assert_eq!(result, raw_bytes); + } + + #[test] + fn decode_multibase_key_secp256k1_returns_raw_bytes() { + let raw_bytes = vec![0x02, 0x11, 0x22, 0x33]; + let encoded = multibase::encode(multibase::Base::Base58Btc, &raw_bytes); + let result = decode_multibase_key(&encoded, "EcdsaSecp256k1VerificationKey2019").unwrap(); + assert_eq!(result, raw_bytes); + } + + #[test] + fn decode_multibase_key_unknown_type_rejected() { + let raw_bytes = vec![0x80, 0x24, 0x01, 0x02, 0x03]; + let encoded = multibase::encode(multibase::Base::Base58Btc, &raw_bytes); + let result = decode_multibase_key(&encoded, "UnknownKeyType2099"); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("unsupported"), + "error should mention unsupported: {msg}" + ); + } + + #[test] + fn decode_multibase_key_multikey_too_short() { + let short_bytes = vec![0x80]; + let encoded = multibase::encode(multibase::Base::Base58Btc, &short_bytes); + let result = decode_multibase_key(&encoded, "Multikey"); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("too short"), + "error should mention too short: {msg}" + ); + } + + #[test] + fn decode_multibase_key_multikey_strips_prefix() { + let mut bytes = vec![0x80, 0x24]; + bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]); + let encoded = multibase::encode(multibase::Base::Base58Btc, &bytes); + let result = decode_multibase_key(&encoded, "Multikey").unwrap(); + assert_eq!(result, vec![0xAA, 0xBB, 0xCC]); + } +} diff --git a/src/jetstream.rs b/src/jetstream.rs --- a/src/jetstream.rs +++ b/src/jetstream.rs @@ -291,7 +291,7 @@ } } "identity" => { if let Some(identity) = event.identity { - tracing::info!( + tracing::debug!( did = %identity.did, handle = ?identity.handle, "received identity event from jetstream" diff --git a/src/lib.rs b/src/lib.rs --- a/src/lib.rs +++ b/src/lib.rs @@ -17,7 +17,9 @@ pub mod jetstream; pub mod labeler; pub mod lexicon; pub mod lua; +pub mod lua_analysis; pub mod oauth; +pub mod plc; pub mod plugin; pub mod profile; pub mod proxy_config; @@ -27,6 +29,9 @@ pub mod record_refs; pub mod repo; pub mod resolve; pub mod server; +pub mod service_entries; +pub mod service_identity; +pub mod setup; pub mod spaces; pub mod xrpc; diff --git a/src/lua/execute.rs b/src/lua/execute.rs --- a/src/lua/execute.rs +++ b/src/lua/execute.rs @@ -60,7 +60,7 @@ // Capture script source and input for error logging before anything is consumed. let script_source = script.to_string(); let input_json = input.clone(); - let pds_auth = if let Some(client_key) = claims.client_key() { + let pds_auth: Option = if let Some(client_key) = claims.client_key() { let encryption_key = state .config .token_encryption_key @@ -96,38 +96,16 @@ let dpop_key_id = claims .dpop_key_id() .ok_or_else(|| AppError::Internal("DPoP key ID not available in claims".into()))? .to_string(); - repo::PdsAuth::Dpop { + Some(repo::PdsAuth::Dpop { api_client_id, dpop_key_id, encryption_key: *encryption_key, - } + }) } else { - match repo::get_oauth_session(state, claims.did()).await { - Ok(s) => repo::PdsAuth::OAuth(Arc::new(s)), - Err(e) => { - let error_message = format!("{e}"); - log_event( - &state.db, - EventLog { - event_type: "script.error".to_string(), - severity: Severity::Error, - actor_did: Some(claims.did().to_string()), - subject: Some(method.to_string()), - detail: serde_json::json!({ - "error": error_message, - "script_source": script_source, - "input": input_json, - "caller_did": claims.did(), - "method": method, - "duration_ms": start.elapsed().as_millis() as u64, - }), - }, - backend, - ) - .await; - return Err(e); - } - } + repo::get_oauth_session(state, claims.did()) + .await + .ok() + .map(|s| repo::PdsAuth::OAuth(Arc::new(s))) }; let lua = match sandbox::create_sandbox() { @@ -159,7 +137,7 @@ }; let state_arc = Arc::new(state.clone()); let claims_arc = Arc::new(claims.clone()); - let pds_auth_arc = Arc::new(pds_auth); + let pds_auth_arc = pds_auth.map(Arc::new); if let Err(e) = db_api::register_db_api(&lua, state_arc.clone()) { let error_message = format!("failed to register db API: {e}"); @@ -259,12 +237,14 @@ .await; return Err(AppError::Internal(error_message)); } - if let Err(e) = atproto_api::register_atproto_blob_api( - &lua, - state_arc.clone(), - claims_arc.clone(), - pds_auth_arc.clone(), - ) { + if let Some(ref pds_auth) = pds_auth_arc + && let Err(e) = atproto_api::register_atproto_blob_api( + &lua, + state_arc.clone(), + claims_arc.clone(), + pds_auth.clone(), + ) + { let error_message = format!("failed to register atproto blob API: {e}"); log_event( &state.db, @@ -292,7 +272,7 @@ if let Err(e) = record::register_record_api( &lua, state_arc.clone(), Some(claims_arc), - Some(pds_auth_arc), + pds_auth_arc, delegate_did.map(|s| s.to_string()), ) { let error_message = format!("failed to register Record API: {e}"); diff --git a/src/lua/xrpc_api.rs b/src/lua/xrpc_api.rs --- a/src/lua/xrpc_api.rs +++ b/src/lua/xrpc_api.rs @@ -170,7 +170,8 @@ } if let Some(ref param_schema) = lex.parameters { xrpc::coerce_params(params, param_schema); } - xrpc::procedure::handle_procedure(state, method, claims, input, params, &lex).await + xrpc::procedure::handle_procedure(state, method, claims, input, params, &lex, None) + .await } None => { let query_string = params_to_query_string(params); diff --git a/src/lua_analysis.rs b/src/lua_analysis.rs new file mode 100644 --- /dev/null +++ b/src/lua_analysis.rs @@ -0,0 +1,240 @@ +use regex::Regex; +use std::sync::LazyLock; + +/// Matches an xrpc.query or xrpc.procedure call and captures the method name. +static XRPC_CALL_RE: LazyLock = LazyLock::new(|| { + Regex::new(r#"xrpc\.(?:query|procedure)\(\s*["']([a-zA-Z][a-zA-Z0-9]*(?:\.[a-zA-Z][a-zA-Z0-9]*)*)["']"#).unwrap() +}); + +/// Matches a Lua line comment at the start of the non-whitespace content on a line. +/// Used to detect lines that are fully commented out before any code. +static LUA_COMMENT_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*--").unwrap()); + +/// Strips Lua block comments (`--[[ ... ]]`) from source, including multi-line ones. +static BLOCK_COMMENT_RE: LazyLock = + LazyLock::new(|| Regex::new(r"--\[\[[\s\S]*?\]\]").unwrap()); + +pub fn extract_outbound_xrpcs(source: &str) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut result = Vec::new(); + + let stripped = BLOCK_COMMENT_RE.replace_all(source, ""); + + for line in stripped.lines() { + if LUA_COMMENT_RE.is_match(line) { + continue; + } + + for cap in XRPC_CALL_RE.captures_iter(line) { + if let Some(method) = cap.get(1) { + let method = method.as_str().to_string(); + if seen.insert(method.clone()) { + result.push(method); + } + } + } + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_script_returns_empty() { + let result = extract_outbound_xrpcs(""); + assert!(result.is_empty()); + } + + #[test] + fn no_xrpc_calls_returns_empty() { + let source = r#" + local record = params.record + return { records = { record } } + "#; + let result = extract_outbound_xrpcs(source); + assert!(result.is_empty()); + } + + #[test] + fn detects_xrpc_query_call() { + let source = r#" + local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) + return result + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!(result, vec!["games.birb.chess.getGame"]); + } + + #[test] + fn detects_xrpc_procedure_call() { + let source = r#" + xrpc.procedure("games.birb.chess.makeMove", { game = params.game, move = params.move }) + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!(result, vec!["games.birb.chess.makeMove"]); + } + + #[test] + fn detects_multiple_calls() { + let source = r#" + local game = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) + xrpc.procedure("games.birb.chess.makeMove", { game = game.uri, move = params.move }) + local games = xrpc.query("games.birb.chess.listGames", {}) + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!( + result, + vec![ + "games.birb.chess.getGame", + "games.birb.chess.makeMove", + "games.birb.chess.listGames", + ] + ); + } + + #[test] + fn deduplicates_repeated_calls() { + let source = r#" + local a = xrpc.query("games.birb.chess.getGame", { uri = "a" }) + local b = xrpc.query("games.birb.chess.getGame", { uri = "b" }) + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!(result, vec!["games.birb.chess.getGame"]); + } + + #[test] + fn ignores_commented_out_calls() { + let source = r#" + -- local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) + return {} + "#; + let result = extract_outbound_xrpcs(source); + assert!(result.is_empty()); + } + + #[test] + fn handles_single_quotes_and_double_quotes() { + let source = r#" + local a = xrpc.query('games.birb.chess.getGame', {}) + local b = xrpc.query("games.birb.chess.listGames", {}) + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!( + result, + vec!["games.birb.chess.getGame", "games.birb.chess.listGames",] + ); + } + + #[test] + fn handles_multiline_scripts() { + let source = r#" +function handle(input, params) + local game = xrpc.query("games.birb.chess.getGame", + { uri = params.uri }) + local result = xrpc.procedure("games.birb.chess.makeMove", + { game = game.uri, + move = params.move }) + return result +end + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!( + result, + vec!["games.birb.chess.getGame", "games.birb.chess.makeMove",] + ); + } + + #[test] + fn handles_mixed_comments_and_code() { + let source = r#" +function handle(input, params) + -- This is a comment about the next call + local game = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) + -- local old = xrpc.query("games.birb.chess.oldEndpoint", {}) + -- xrpc.procedure("games.birb.chess.deprecatedMove", {}) + xrpc.procedure("games.birb.chess.makeMove", { game = game.uri }) + return game +end + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!( + result, + vec!["games.birb.chess.getGame", "games.birb.chess.makeMove",] + ); + } + + #[test] + fn ignores_block_comment_single_line() { + let source = r#" + --[[ local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) ]] + return {} + "#; + let result = extract_outbound_xrpcs(source); + assert!(result.is_empty()); + } + + #[test] + fn ignores_block_comment_multiline() { + let source = r#" + --[[ + local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) + xrpc.procedure("games.birb.chess.makeMove", {}) + ]] + local active = xrpc.query("games.birb.chess.listGames", {}) + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!(result, vec!["games.birb.chess.listGames"]); + } + + #[test] + fn dynamic_method_names_not_detected() { + let source = r#" + local method = "games.birb.chess.getGame" + local result = xrpc.query(method, {}) + "#; + let result = extract_outbound_xrpcs(source); + assert!( + result.is_empty(), + "dynamically constructed method names should not be detected" + ); + } + + #[test] + fn extracts_from_complex_lua() { + let source = r#" +function handle(input, params) + local results = {} + + if params.include_profile then + local profile = xrpc.query("app.bsky.actor.getProfile", { actor = params.did }) + table.insert(results, profile) + end + + for i = 1, params.count do + local feed = xrpc.query("app.bsky.feed.getAuthorFeed", { actor = params.did, limit = 10 }) + for _, post in ipairs(feed.feed) do + table.insert(results, post) + end + end + + if params.should_notify then + xrpc.procedure("games.birb.chess.sendNotification", { target = params.did }) + end + + return { items = results } +end + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!( + result, + vec![ + "app.bsky.actor.getProfile", + "app.bsky.feed.getAuthorFeed", + "games.birb.chess.sendNotification", + ] + ); + } +} diff --git a/src/main.rs b/src/main.rs --- a/src/main.rs +++ b/src/main.rs @@ -442,9 +442,10 @@ }; let oauth_state_store = DbStateStore::new(db_pool.clone(), db_backend); - // HappyView's own default OAuth client always uses the `atproto` scope. - // API clients configure their own scopes via the API Clients settings page. - let oauth_scopes = vec![Scope::Known(KnownScope::Atproto)]; + let oauth_scopes = vec![ + Scope::Known(KnownScope::Atproto), + Scope::Unknown("identity:*".to_string()), + ]; let oauth_client = if is_loopback { info!("Using loopback OAuth client metadata (local development)"); diff --git a/src/oauth/routes.rs b/src/oauth/routes.rs --- a/src/oauth/routes.rs +++ b/src/oauth/routes.rs @@ -245,7 +245,18 @@ )); } // Validate scopes - client_auth::validate_scopes(&body.scopes, &client.scopes, &state.lexicons).await?; + if let Err(e) = + client_auth::validate_scopes(&body.scopes, &client.scopes, &state.lexicons).await + { + tracing::warn!( + client_key = %client_key, + did = %body.did, + token_scopes = %body.scopes, + client_scopes = %client.scopes, + "session registration scope validation failed" + ); + return Err(e); + } // Store the session let session_id = Uuid::new_v4().to_string(); diff --git a/src/plc.rs b/src/plc.rs new file mode 100644 --- /dev/null +++ b/src/plc.rs @@ -0,0 +1,361 @@ +use crate::error::AppError; +use base64::Engine; +use p256::ecdsa::{SigningKey, signature::Signer}; +use sha2::{Digest, Sha256}; + +/// Parameters for building a PLC genesis operation. +pub struct PlcGenesisParams { + /// The rotation key in did:key multibase format (e.g. "did:key:z...") + pub rotation_key_did_key: String, + /// The signing key in did:key multibase format (e.g. "did:key:z...") + pub signing_key_did_key: String, + /// Service entries: (key, type, endpoint) — e.g. ("atproto_labeler", "AtprotoLabeler", "https://...") + pub service_entries: Vec<(String, String, String)>, +} + +/// Build the unsigned genesis operation (no `sig` field). +pub fn build_unsigned_genesis(params: &PlcGenesisParams) -> serde_json::Value { + let mut services = serde_json::Map::new(); + for (key, svc_type, endpoint) in ¶ms.service_entries { + services.insert( + key.clone(), + serde_json::json!({ + "type": svc_type, + "endpoint": endpoint, + }), + ); + } + + serde_json::json!({ + "type": "plc_operation", + "rotationKeys": [¶ms.rotation_key_did_key], + "verificationMethods": { + "atproto": ¶ms.signing_key_did_key, + }, + "alsoKnownAs": [], + "services": services, + "prev": null, + }) +} + +/// Sign an unsigned PLC operation with the rotation key. +/// +/// The signature covers the DAG-CBOR encoding of the unsigned operation +/// (all fields except `sig`). ECDSA P-256 internally SHA-256 hashes the +/// message before signing. +pub fn sign_operation( + unsigned_op: &serde_json::Value, + rotation_key: &SigningKey, +) -> Result { + let cbor = serde_ipld_dagcbor::to_vec(unsigned_op) + .map_err(|e| AppError::Internal(format!("DAG-CBOR encoding failed: {e}")))?; + + // p256 Signer::sign hashes the message with SHA-256 internally (standard ECDSA) + let signature: p256::ecdsa::Signature = rotation_key.sign(&cbor); + let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let mut signed = unsigned_op.clone(); + signed + .as_object_mut() + .unwrap() + .insert("sig".to_string(), serde_json::json!(sig_b64)); + Ok(signed) +} + +/// Derive the `did:plc:` identifier from a **signed** genesis operation. +/// +/// Steps: +/// 1. DAG-CBOR encode the signed operation +/// 2. SHA-256 hash the encoding +/// 3. Base32-lower encode the hash (RFC 4648 lowercase, no padding) +/// 4. Truncate to 24 characters +/// 5. Prefix with `did:plc:` +pub fn derive_did(signed_op: &serde_json::Value) -> Result { + let cbor = serde_ipld_dagcbor::to_vec(signed_op) + .map_err(|e| AppError::Internal(format!("DAG-CBOR encoding failed: {e}")))?; + let hash = Sha256::digest(&cbor); + let encoded = data_encoding::BASE32_NOPAD.encode(&hash).to_lowercase(); + let truncated = &encoded[..24]; + Ok(format!("did:plc:{truncated}")) +} + +/// Submit a signed PLC operation (genesis or update) to the PLC directory. +/// +/// POST `{plc_url}/{did}` with the signed operation as JSON body. +pub async fn submit_operation( + http: &reqwest::Client, + plc_url: &str, + did: &str, + signed_op: &serde_json::Value, +) -> Result<(), AppError> { + let url = format!("{}/{}", plc_url.trim_end_matches('/'), did); + let resp = http + .post(&url) + .json(signed_op) + .send() + .await + .map_err(|e| AppError::Internal(format!("PLC submission failed: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AppError::Internal(format!( + "PLC directory returned {status}: {body}" + ))); + } + Ok(()) +} + +/// Backwards-compatible alias for `submit_operation`. +pub async fn submit_genesis( + http: &reqwest::Client, + plc_url: &str, + did: &str, + signed_op: &serde_json::Value, +) -> Result<(), AppError> { + submit_operation(http, plc_url, did, signed_op).await +} + +/// Fetch the last PLC audit log entry for a DID. +/// +/// GET `{plc_url}/{did}/log/last` returns the last operation with a `cid` field. +pub async fn fetch_last_operation( + http: &reqwest::Client, + plc_url: &str, + did: &str, +) -> Result { + let url = format!("{}/{}/log/last", plc_url.trim_end_matches('/'), did); + let resp = http + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(format!("failed to fetch PLC log: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(AppError::Internal(format!( + "PLC directory returned {status} for log/last: {body}" + ))); + } + + resp.json() + .await + .map_err(|e| AppError::Internal(format!("failed to parse PLC log: {e}"))) +} + +/// Extract the `cid` field from a PLC audit log entry (used as `prev` in update operations). +pub fn extract_prev_cid(last_op: &serde_json::Value) -> Result { + last_op["cid"] + .as_str() + .map(String::from) + .ok_or_else(|| AppError::Internal("no CID in PLC log entry".into())) +} + +/// Build an unsigned PLC update operation. +/// +/// Unlike a genesis operation, this has `prev` set to the CID of the last operation +/// and preserves existing fields from the current DID document. +pub fn build_update_operation( + prev: &str, + rotation_keys: Vec, + verification_methods: serde_json::Map, + also_known_as: Vec, + services: serde_json::Map, +) -> serde_json::Value { + serde_json::json!({ + "type": "plc_operation", + "rotationKeys": rotation_keys, + "verificationMethods": verification_methods, + "alsoKnownAs": also_known_as, + "services": services, + "prev": prev, + }) +} + +/// Decrypt an encrypted key from the database and return the raw bytes. +pub fn decrypt_key(enc_b64: &str, encryption_key: &[u8; 32]) -> Result, AppError> { + let encrypted = base64::engine::general_purpose::STANDARD + .decode(enc_b64) + .map_err(|e| AppError::Internal(format!("failed to decode key: {e}")))?; + + crate::plugin::encryption::decrypt(encryption_key, &encrypted) + .map_err(|e| AppError::Internal(format!("failed to decrypt key: {e}"))) +} + +/// Convert raw P-256 private key bytes to a did:key multibase string. +/// +/// Uses the same multikey format as `extract_public_key_multibase` in server.rs: +/// multicodec varint prefix 0x8024 (P-256) + compressed public key, base58btc-encoded. +pub fn private_key_to_did_key(key_bytes: &[u8]) -> Result { + let signing_key = SigningKey::from_bytes(key_bytes.into()) + .map_err(|e| AppError::Internal(format!("invalid signing key: {e}")))?; + let public_key = signing_key.verifying_key(); + let compressed = public_key.to_encoded_point(true); + + // Multikey: 0x8024 varint prefix for P-256 + compressed public key bytes + let mut multikey_bytes = vec![0x80, 0x24]; + multikey_bytes.extend_from_slice(compressed.as_bytes()); + let encoded = multibase::encode(multibase::Base::Base58Btc, &multikey_bytes); + Ok(format!("did:key:{encoded}")) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::RngCore; + + /// Generate a test P-256 signing key using rand 0.9 (avoids rand_core version mismatch + /// with p256's SigningKey::random which expects rand_core 0.6). + fn test_signing_key() -> SigningKey { + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + SigningKey::from_bytes((&bytes[..]).into()).unwrap() + } + + #[test] + fn build_unsigned_genesis_structure() { + let params = PlcGenesisParams { + rotation_key_did_key: "did:key:zRotation".into(), + signing_key_did_key: "did:key:zSigning".into(), + service_entries: vec![( + "atproto_labeler".into(), + "AtprotoLabeler".into(), + "https://example.com".into(), + )], + }; + + let op = build_unsigned_genesis(¶ms); + assert_eq!(op["type"], "plc_operation"); + assert_eq!(op["prev"], serde_json::Value::Null); + assert_eq!(op["rotationKeys"][0], "did:key:zRotation"); + assert_eq!(op["verificationMethods"]["atproto"], "did:key:zSigning"); + assert_eq!(op["services"]["atproto_labeler"]["type"], "AtprotoLabeler"); + assert_eq!( + op["services"]["atproto_labeler"]["endpoint"], + "https://example.com" + ); + assert_eq!(op["alsoKnownAs"].as_array().unwrap().len(), 0); + // No sig field on unsigned op + assert!(op.get("sig").is_none()); + } + + #[test] + fn sign_operation_adds_sig() { + let params = PlcGenesisParams { + rotation_key_did_key: "did:key:zTest".into(), + signing_key_did_key: "did:key:zTest".into(), + service_entries: vec![], + }; + let unsigned = build_unsigned_genesis(¶ms); + + let key = test_signing_key(); + let signed = sign_operation(&unsigned, &key).unwrap(); + + assert!(signed.get("sig").is_some()); + let sig = signed["sig"].as_str().unwrap(); + // base64url-encoded P-256 ECDSA signature should be non-empty + assert!(!sig.is_empty()); + // All other fields preserved + assert_eq!(signed["type"], "plc_operation"); + assert_eq!(signed["prev"], serde_json::Value::Null); + } + + #[test] + fn derive_did_format() { + let params = PlcGenesisParams { + rotation_key_did_key: "did:key:zTest".into(), + signing_key_did_key: "did:key:zTest".into(), + service_entries: vec![], + }; + let unsigned = build_unsigned_genesis(¶ms); + let key = test_signing_key(); + let signed = sign_operation(&unsigned, &key).unwrap(); + + let did = derive_did(&signed).unwrap(); + assert!(did.starts_with("did:plc:")); + // 24-char truncated hash after prefix + let suffix = did.strip_prefix("did:plc:").unwrap(); + assert_eq!(suffix.len(), 24); + // Should be lowercase base32 (a-z, 2-7) + assert!( + suffix + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) + ); + } + + #[test] + fn derive_did_deterministic() { + let params = PlcGenesisParams { + rotation_key_did_key: "did:key:zTest".into(), + signing_key_did_key: "did:key:zTest".into(), + service_entries: vec![], + }; + let unsigned = build_unsigned_genesis(¶ms); + let key = test_signing_key(); + let signed = sign_operation(&unsigned, &key).unwrap(); + + let did1 = derive_did(&signed).unwrap(); + let did2 = derive_did(&signed).unwrap(); + assert_eq!(did1, did2); + } + + #[test] + fn private_key_to_did_key_roundtrip() { + let key = test_signing_key(); + let key_bytes = key.to_bytes(); + let did_key = private_key_to_did_key(&key_bytes).unwrap(); + assert!(did_key.starts_with("did:key:z")); + } + + #[test] + fn decrypt_key_invalid_base64() { + let encryption_key = [0x42u8; 32]; + let result = decrypt_key("not valid base64!!!", &encryption_key); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("decode"), + "error should mention decoding: {msg}" + ); + } + + #[test] + fn decrypt_key_wrong_encryption_key() { + let correct_key = [0x42u8; 32]; + let wrong_key = [0x99u8; 32]; + + let plaintext = [0xAAu8; 32]; + let encrypted = crate::plugin::encryption::encrypt(&correct_key, &plaintext).unwrap(); + let enc_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); + + let result = decrypt_key(&enc_b64, &wrong_key); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("decrypt"), + "error should mention decryption: {msg}" + ); + } + + #[test] + fn private_key_to_did_key_rejects_invalid_bytes() { + let result = private_key_to_did_key(&[0x00; 32]); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("invalid signing key"), + "error should mention invalid: {msg}" + ); + } + + #[test] + fn extract_prev_cid_missing_field() { + let op = serde_json::json!({"type": "plc_operation"}); + let result = extract_prev_cid(&op); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!(msg.contains("CID"), "error should mention CID: {msg}"); + } +} diff --git a/src/proxy_config.rs b/src/proxy_config.rs --- a/src/proxy_config.rs +++ b/src/proxy_config.rs @@ -159,6 +159,25 @@ assert!(config.allows("com.other.feed.getHot")); } #[test] + fn wildcard_does_not_match_prefix_without_dot() { + let config = ProxyConfig { + mode: ProxyMode::Allowlist, + nsids: vec!["com.example.*".into()], + }; + assert!( + !config.allows("com.example"), + "bare prefix should not match wildcard" + ); + } + + #[test] + fn validate_rejects_invalid_characters() { + assert!(validate_nsid_pattern("com.ex@mple.foo").is_err()); + assert!(validate_nsid_pattern("com.ex mple.foo").is_err()); + assert!(validate_nsid_pattern("com.ex_mple.foo").is_err()); + } + + #[test] fn validate_valid_nsids() { assert!(validate_nsid_pattern("com.example.feed.getHot").is_ok()); assert!(validate_nsid_pattern("com.example.*").is_ok()); diff --git a/src/server.rs b/src/server.rs --- a/src/server.rs +++ b/src/server.rs @@ -3,6 +3,7 @@ use axum::http::{Method, header}; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::{get, post}; use axum::{Json, Router}; +use base64::Engine; use bytes::Bytes; use http_body_util::Full; use std::convert::Infallible; @@ -73,6 +74,8 @@ .nest("/external-auth", crate::external_auth::routes()) .nest("/oauth", crate::oauth::routes::routes()) // https://atproto.com/specs/oauth#types-of-clients .route("/oauth-client-metadata.json", get(client_metadata)) + .route("/.well-known/did.json", get(well_known_did_json)) + .nest("/api/setup", crate::setup::routes()) .route("/xrpc/app.bsky.actor.getProfile", get(get_profile)) .route( "/xrpc/com.atproto.repo.uploadBlob", @@ -304,6 +307,81 @@ metadata["policy_uri"] = serde_json::Value::String(uri); } Json(metadata) +} + +fn extract_public_key_multibase( + identity: &crate::service_identity::ServiceIdentity, + state: &AppState, +) -> Result { + let enc_b64 = identity + .signing_key_enc + .as_ref() + .ok_or_else(|| AppError::Internal("no signing key configured".into()))?; + + let encrypted = base64::engine::general_purpose::STANDARD + .decode(enc_b64) + .map_err(|e| AppError::Internal(format!("invalid signing key encoding: {e}")))?; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + let private_bytes = crate::plugin::encryption::decrypt(encryption_key, &encrypted) + .map_err(|e| AppError::Internal(format!("failed to decrypt signing key: {e}")))?; + + let signing_key = p256::ecdsa::SigningKey::from_bytes(private_bytes.as_slice().into()) + .map_err(|e| AppError::Internal(format!("invalid signing key: {e}")))?; + let public_key = signing_key.verifying_key(); + let compressed = public_key.to_encoded_point(true); + + // Multikey format: multicodec varint prefix for P-256 (0x1200) then base58btc with 'z' prefix + let mut multikey_bytes = vec![0x80, 0x24]; + multikey_bytes.extend_from_slice(compressed.as_bytes()); + let encoded = multibase::encode(multibase::Base::Base58Btc, &multikey_bytes); + Ok(encoded) +} + +async fn well_known_did_json( + State(state): State, + headers: axum::http::HeaderMap, +) -> Result, AppError> { + let identity = crate::service_identity::get_identity(&state.db, state.db_backend).await?; + let identity = + identity.ok_or_else(|| AppError::NotFound("no service identity configured".into()))?; + + if identity.mode != crate::service_identity::IdentityMode::DidWeb { + return Err(AppError::NotFound( + "DID document only served in did:web mode".into(), + )); + } + + let host = headers + .get(axum::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| AppError::BadRequest("missing Host header".into()))?; + + let entries = crate::service_entries::list_entries(&state.db, state.db_backend).await?; + let entry_pairs: Vec<(String, String)> = entries + .iter() + .map(|e| (e.fragment_id.clone(), e.service_type.clone())) + .collect(); + + let service_endpoint = format!("https://{host}"); + + let signing_key_multibase = extract_public_key_multibase(&identity, &state)?; + + let doc = crate::service_identity::generate_did_document( + &identity, + host, + &signing_key_multibase, + &entry_pairs, + &service_endpoint, + ) + .ok_or_else(|| AppError::NotFound("DID document not available".into()))?; + + Ok(Json(doc)) } async fn get_profile( diff --git a/src/service_entries.rs b/src/service_entries.rs new file mode 100644 --- /dev/null +++ b/src/service_entries.rs @@ -0,0 +1,357 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sqlx::AnyPool; + +use crate::db::{DatabaseBackend, adapt_sql}; +use crate::error::AppError; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize)] +pub struct ServiceEntry { + pub id: i64, + pub fragment_id: String, + pub service_type: String, + pub access_mode: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Deserialize)] +pub struct CreateServiceEntry { + pub fragment_id: String, + pub service_type: String, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateServiceEntry { + pub fragment_id: Option, + pub service_type: Option, + pub access_mode: Option, +} + +// --------------------------------------------------------------------------- +// Row type for service_entries +// --------------------------------------------------------------------------- + +type ServiceEntryRow = (i64, String, String, String, String, String); + +fn parse_service_entry_row(r: ServiceEntryRow) -> ServiceEntry { + ServiceEntry { + id: r.0, + fragment_id: r.1, + service_type: r.2, + access_mode: r.3, + created_at: r.4, + updated_at: r.5, + } +} + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- + +/// SELECT all service entries ordered by id. +pub async fn list_entries( + db: &AnyPool, + backend: DatabaseBackend, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries ORDER BY id", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .fetch_all(db) + .await + .map_err(|e| AppError::Internal(format!("failed to list service entries: {e}")))?; + + Ok(rows.into_iter().map(parse_service_entry_row).collect()) +} + +/// INSERT a new service entry with access_mode='all', then return the created row. +pub async fn create_entry( + db: &AnyPool, + backend: DatabaseBackend, + body: &CreateServiceEntry, +) -> Result { + let now = Utc::now().to_rfc3339(); + + let insert_sql = adapt_sql( + "INSERT INTO service_entries (fragment_id, service_type, access_mode, created_at, updated_at) VALUES (?, ?, 'all', ?, ?) RETURNING id", + backend, + ); + + let row: (i64,) = sqlx::query_as(&insert_sql) + .bind(&body.fragment_id) + .bind(&body.service_type) + .bind(&now) + .bind(&now) + .fetch_one(db) + .await + .map_err(|e| AppError::Internal(format!("failed to create service entry: {e}")))?; + + let id = row.0; + + let fetch_sql = adapt_sql( + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries WHERE id = ?", + backend, + ); + + let entry_row: ServiceEntryRow = sqlx::query_as(&fetch_sql) + .bind(id) + .fetch_one(db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch created service entry: {e}")))?; + + Ok(parse_service_entry_row(entry_row)) +} + +/// Dynamic UPDATE — only provided fields are changed. +pub async fn update_entry( + db: &AnyPool, + backend: DatabaseBackend, + id: i64, + body: &UpdateServiceEntry, +) -> Result { + if let Some(mode) = &body.access_mode + && mode != "all" + && mode != "specific" + { + return Err(AppError::BadRequest(format!( + "invalid access_mode '{mode}': must be 'all' or 'specific'" + ))); + } + + let now = Utc::now().to_rfc3339(); + + let mut set_clauses: Vec<&str> = Vec::new(); + if body.fragment_id.is_some() { + set_clauses.push("fragment_id = ?"); + } + if body.service_type.is_some() { + set_clauses.push("service_type = ?"); + } + if body.access_mode.is_some() { + set_clauses.push("access_mode = ?"); + } + set_clauses.push("updated_at = ?"); + + if set_clauses.len() == 1 { + // Only updated_at — nothing meaningful to update; just fetch current state. + let fetch_sql = adapt_sql( + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries WHERE id = ?", + backend, + ); + let row: Option = sqlx::query_as(&fetch_sql) + .bind(id) + .fetch_optional(db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch service entry: {e}")))?; + return row + .map(parse_service_entry_row) + .ok_or_else(|| AppError::NotFound(format!("service entry {id} not found"))); + } + + let raw = format!( + "UPDATE service_entries SET {} WHERE id = ?", + set_clauses.join(", ") + ); + let update_sql = adapt_sql(&raw, backend); + + let mut query = sqlx::query(&update_sql); + if let Some(v) = &body.fragment_id { + query = query.bind(v.as_str()); + } + if let Some(v) = &body.service_type { + query = query.bind(v.as_str()); + } + if let Some(v) = &body.access_mode { + query = query.bind(v.as_str()); + } + query = query.bind(&now).bind(id); + + let result = query + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to update service entry: {e}")))?; + + if result.rows_affected() == 0 { + return Err(AppError::NotFound(format!("service entry {id} not found"))); + } + + let fetch_sql = adapt_sql( + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries WHERE id = ?", + backend, + ); + let row: ServiceEntryRow = sqlx::query_as(&fetch_sql) + .bind(id) + .fetch_one(db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch updated service entry: {e}")))?; + + Ok(parse_service_entry_row(row)) +} + +/// DELETE a service entry by id. +pub async fn delete_entry( + db: &AnyPool, + backend: DatabaseBackend, + id: i64, +) -> Result { + let sql = adapt_sql("DELETE FROM service_entries WHERE id = ?", backend); + + let result = sqlx::query(&sql) + .bind(id) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to delete service entry: {e}")))?; + + Ok(result.rows_affected() > 0) +} + +// --------------------------------------------------------------------------- +// Junction table: service_entry_xrpcs +// --------------------------------------------------------------------------- + +/// SELECT lexicon_ids associated with a service entry. +pub async fn list_entry_xrpcs( + db: &AnyPool, + backend: DatabaseBackend, + entry_id: i64, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT lexicon_id FROM service_entry_xrpcs WHERE service_entry_id = ? ORDER BY lexicon_id", + backend, + ); + + let rows: Vec<(String,)> = sqlx::query_as(&sql) + .bind(entry_id) + .fetch_all(db) + .await + .map_err(|e| AppError::Internal(format!("failed to list entry xrpcs: {e}")))?; + + Ok(rows.into_iter().map(|r| r.0).collect()) +} + +/// INSERT each lexicon_id for the entry with ON CONFLICT DO NOTHING. +pub async fn add_entry_xrpcs( + db: &AnyPool, + backend: DatabaseBackend, + entry_id: i64, + lexicon_ids: &[String], +) -> Result<(), AppError> { + let sql = adapt_sql( + "INSERT INTO service_entry_xrpcs (service_entry_id, lexicon_id) VALUES (?, ?) ON CONFLICT DO NOTHING", + backend, + ); + + for lexicon_id in lexicon_ids { + sqlx::query(&sql) + .bind(entry_id) + .bind(lexicon_id.as_str()) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to add entry xrpc: {e}")))?; + } + + Ok(()) +} + +/// DELETE each lexicon_id association for the entry. +pub async fn remove_entry_xrpcs( + db: &AnyPool, + backend: DatabaseBackend, + entry_id: i64, + lexicon_ids: &[String], +) -> Result<(), AppError> { + let sql = adapt_sql( + "DELETE FROM service_entry_xrpcs WHERE service_entry_id = ? AND lexicon_id = ?", + backend, + ); + + for lexicon_id in lexicon_ids { + sqlx::query(&sql) + .bind(entry_id) + .bind(lexicon_id.as_str()) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to remove entry xrpc: {e}")))?; + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Access checks +// --------------------------------------------------------------------------- + +/// Return true if the fragment/xrpc combination is accessible. +/// +/// - access_mode = 'all' → always true +/// - access_mode = 'specific' → true only if xrpc_method is in the junction table +pub async fn check_access( + db: &AnyPool, + backend: DatabaseBackend, + fragment_id: &str, + xrpc_method: &str, +) -> Result { + let sql = adapt_sql( + "SELECT id, access_mode FROM service_entries WHERE fragment_id = ? LIMIT 1", + backend, + ); + + let row: Option<(i64, String)> = sqlx::query_as(&sql) + .bind(fragment_id) + .fetch_optional(db) + .await + .map_err(|e| AppError::Internal(format!("failed to check service entry access: {e}")))?; + + let (entry_id, access_mode) = match row { + None => return Ok(false), + Some(r) => r, + }; + + if access_mode == "all" { + return Ok(true); + } + + // access_mode = "specific" — check junction table + let check_sql = adapt_sql( + "SELECT 1 FROM service_entry_xrpcs WHERE service_entry_id = ? AND lexicon_id = ? LIMIT 1", + backend, + ); + + let found: Option<(i32,)> = sqlx::query_as(&check_sql) + .bind(entry_id) + .bind(xrpc_method) + .fetch_optional(db) + .await + .map_err(|e| AppError::Internal(format!("failed to check xrpc access: {e}")))?; + + Ok(found.is_some()) +} + +/// Return all service entries that grant access to a given lexicon. +/// +/// Includes entries where access_mode='all' or where the lexicon_id is in the junction table. +pub async fn services_for_lexicon( + db: &AnyPool, + backend: DatabaseBackend, + lexicon_id: &str, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT id, fragment_id, service_type, access_mode, created_at, updated_at FROM service_entries WHERE access_mode = 'all' OR EXISTS (SELECT 1 FROM service_entry_xrpcs WHERE service_entry_xrpcs.service_entry_id = service_entries.id AND service_entry_xrpcs.lexicon_id = ?) ORDER BY id", + backend, + ); + + let rows: Vec = sqlx::query_as(&sql) + .bind(lexicon_id) + .fetch_all(db) + .await + .map_err(|e| AppError::Internal(format!("failed to query services for lexicon: {e}")))?; + + Ok(rows.into_iter().map(parse_service_entry_row).collect()) +} diff --git a/src/service_identity.rs b/src/service_identity.rs new file mode 100644 --- /dev/null +++ b/src/service_identity.rs @@ -0,0 +1,350 @@ +use crate::db::{DatabaseBackend, adapt_sql}; +use crate::error::AppError; +use serde::{Deserialize, Serialize}; +use sqlx::AnyPool; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum IdentityMode { + DidWeb, + DidPlc, + AttachAccount, + NotExposed, +} + +impl IdentityMode { + pub fn as_str(&self) -> &'static str { + match self { + Self::DidWeb => "did_web", + Self::DidPlc => "did_plc", + Self::AttachAccount => "attach_account", + Self::NotExposed => "not_exposed", + } + } + + pub fn parse(s: &str) -> Option { + match s { + "did_web" => Some(Self::DidWeb), + "did_plc" => Some(Self::DidPlc), + "attach_account" => Some(Self::AttachAccount), + "not_exposed" => Some(Self::NotExposed), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct ServiceIdentity { + pub mode: IdentityMode, + pub did: Option, + pub signing_key_enc: Option, + pub attached_account_did: Option, + pub setup_complete: bool, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SetupStatus { + pub identity_mode: Option, + pub identity_configured: bool, + pub plc_verified: bool, + pub setup_complete: bool, +} + +type ServiceIdentityRow = ( + String, + Option, + Option, + Option, + i32, + String, + String, +); + +fn parse_row(r: ServiceIdentityRow) -> Result { + let mode = IdentityMode::parse(&r.0) + .ok_or_else(|| AppError::Internal(format!("invalid identity mode: {}", r.0)))?; + Ok(ServiceIdentity { + mode, + did: r.1, + signing_key_enc: r.2, + attached_account_did: r.3, + setup_complete: r.4 != 0, + created_at: r.5, + updated_at: r.6, + }) +} + +/// Fetch the service identity row (id = 1), if it exists. +pub async fn get_identity( + db: &AnyPool, + backend: DatabaseBackend, +) -> Result, AppError> { + let sql = adapt_sql( + "SELECT mode, did, signing_key_enc, attached_account_did, CAST(setup_complete AS INTEGER), created_at, updated_at FROM service_identity WHERE id = 1", + backend, + ); + + let row: Option = sqlx::query_as(&sql) + .fetch_optional(db) + .await + .map_err(|e| AppError::Internal(format!("failed to get service identity: {e}")))?; + + row.map(parse_row).transpose() +} + +/// Derive setup status from the current identity row. +pub async fn get_setup_status( + db: &AnyPool, + backend: DatabaseBackend, +) -> Result { + let identity = get_identity(db, backend).await?; + + match identity { + None => Ok(SetupStatus { + identity_mode: None, + identity_configured: false, + plc_verified: false, + setup_complete: false, + }), + Some(id) => { + let plc_verified = matches!(id.mode, IdentityMode::DidPlc) && id.setup_complete; + let identity_configured = match id.mode { + IdentityMode::DidWeb => id.signing_key_enc.is_some(), + _ => id.did.is_some(), + }; + let setup_complete = id.setup_complete; + let identity_mode = Some(id.mode); + Ok(SetupStatus { + identity_mode, + identity_configured, + plc_verified, + setup_complete, + }) + } + } +} + +/// Insert or update the service identity row (always resets setup_complete to FALSE). +#[allow(clippy::too_many_arguments)] +pub async fn upsert_identity( + db: &AnyPool, + backend: DatabaseBackend, + mode: &IdentityMode, + did: Option<&str>, + signing_key_enc: Option<&str>, + rotation_key_enc: Option<&str>, + attached_account_did: Option<&str>, +) -> Result<(), AppError> { + let now = chrono::Utc::now().to_rfc3339(); + let sql = adapt_sql( + "INSERT INTO service_identity (id, mode, did, signing_key_enc, rotation_key_enc, attached_account_did, setup_complete, created_at, updated_at) + VALUES (1, ?, ?, ?, ?, ?, FALSE, ?, ?) + ON CONFLICT (id) DO UPDATE SET + mode = excluded.mode, + did = excluded.did, + signing_key_enc = excluded.signing_key_enc, + rotation_key_enc = excluded.rotation_key_enc, + attached_account_did = excluded.attached_account_did, + setup_complete = excluded.setup_complete, + updated_at = excluded.updated_at", + backend, + ); + + sqlx::query(&sql) + .bind(mode.as_str()) + .bind(did) + .bind(signing_key_enc) + .bind(rotation_key_enc) + .bind(attached_account_did) + .bind(&now) + .bind(&now) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to upsert service identity: {e}")))?; + + Ok(()) +} + +/// Mark setup as complete for the service identity row. +pub async fn mark_setup_complete(db: &AnyPool, backend: DatabaseBackend) -> Result<(), AppError> { + let now = chrono::Utc::now().to_rfc3339(); + let sql = adapt_sql( + "UPDATE service_identity SET setup_complete = TRUE, updated_at = ? WHERE id = 1", + backend, + ); + + sqlx::query(&sql) + .bind(&now) + .execute(db) + .await + .map_err(|e| AppError::Internal(format!("failed to mark setup complete: {e}")))?; + + Ok(()) +} + +/// Generate a DID document for did:web identity mode. +/// The DID is derived dynamically from the request host rather than stored, +/// so the same signing key works across any domain pointing at this server. +/// Returns None if the identity mode is not DidWeb. +pub fn generate_did_document( + identity: &ServiceIdentity, + host: &str, + signing_key_multibase: &str, + service_entries: &[(String, String)], + service_endpoint: &str, +) -> Option { + if identity.mode != IdentityMode::DidWeb { + return None; + } + + let did = format!("did:web:{}", host.replace(':', "%3A")); + + let verification_method = serde_json::json!([{ + "id": format!("{did}#atproto"), + "type": "Multikey", + "controller": &did, + "publicKeyMultibase": signing_key_multibase + }]); + + let services: Vec = service_entries + .iter() + .map(|(fragment, svc_type)| { + serde_json::json!({ + "id": fragment, + "type": svc_type, + "serviceEndpoint": service_endpoint + }) + }) + .collect(); + + Some(serde_json::json!({ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/multikey/v1" + ], + "id": &did, + "verificationMethod": verification_method, + "service": services + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_identity(mode: IdentityMode, did: Option<&str>) -> ServiceIdentity { + ServiceIdentity { + mode, + did: did.map(String::from), + signing_key_enc: None, + attached_account_did: None, + setup_complete: true, + created_at: "2024-01-01".into(), + updated_at: "2024-01-01".into(), + } + } + + #[test] + fn identity_mode_roundtrip() { + for mode in [ + IdentityMode::DidWeb, + IdentityMode::DidPlc, + IdentityMode::AttachAccount, + IdentityMode::NotExposed, + ] { + let s = mode.as_str(); + let parsed = IdentityMode::parse(s).unwrap(); + assert_eq!(parsed, mode); + } + } + + #[test] + fn identity_mode_from_str_invalid() { + assert!(IdentityMode::parse("invalid").is_none()); + assert!(IdentityMode::parse("").is_none()); + } + + #[test] + fn generate_did_document_returns_none_for_non_web() { + let identity = make_identity(IdentityMode::DidPlc, Some("did:plc:abc123")); + assert!( + generate_did_document(&identity, "example.com", "zKey", &[], "https://example.com") + .is_none() + ); + } + + #[test] + fn generate_did_document_derives_did_from_host() { + let identity = make_identity(IdentityMode::DidWeb, None); + let doc = generate_did_document( + &identity, + "example.com", + "zKey123", + &[], + "https://example.com", + ) + .unwrap(); + assert_eq!(doc["id"], "did:web:example.com"); + } + + #[test] + fn generate_did_document_with_no_entries() { + let identity = make_identity(IdentityMode::DidWeb, None); + let doc = generate_did_document( + &identity, + "example.com", + "zKey123", + &[], + "https://example.com", + ) + .unwrap(); + assert_eq!(doc["id"], "did:web:example.com"); + assert_eq!( + doc["verificationMethod"][0]["publicKeyMultibase"], + "zKey123" + ); + assert_eq!(doc["service"].as_array().unwrap().len(), 0); + } + + #[test] + fn generate_did_document_with_entries() { + let identity = make_identity(IdentityMode::DidWeb, None); + let entries = vec![ + ("#chess".to_string(), "ChessService".to_string()), + ("#checkers".to_string(), "CheckersService".to_string()), + ]; + let doc = generate_did_document( + &identity, + "example.com", + "zKey123", + &entries, + "https://example.com", + ) + .unwrap(); + let services = doc["service"].as_array().unwrap(); + assert_eq!(services.len(), 2); + assert_eq!(services[0]["id"], "#chess"); + assert_eq!(services[0]["type"], "ChessService"); + assert_eq!(services[0]["serviceEndpoint"], "https://example.com"); + assert_eq!(services[1]["id"], "#checkers"); + } + + #[test] + fn generate_did_document_context_and_structure() { + let identity = make_identity(IdentityMode::DidWeb, None); + let doc = + generate_did_document(&identity, "example.com", "zKey", &[], "https://example.com") + .unwrap(); + let context = doc["@context"].as_array().unwrap(); + assert_eq!(context.len(), 2); + assert_eq!(context[0], "https://www.w3.org/ns/did/v1"); + assert_eq!(context[1], "https://w3id.org/security/multikey/v1"); + + let vm = &doc["verificationMethod"][0]; + assert_eq!(vm["id"], "did:web:example.com#atproto"); + assert_eq!(vm["type"], "Multikey"); + assert_eq!(vm["controller"], "did:web:example.com"); + } +} diff --git a/src/setup.rs b/src/setup.rs new file mode 100644 --- /dev/null +++ b/src/setup.rs @@ -0,0 +1,733 @@ +use atrium_api::agent::Agent; +use atrium_api::types::Unknown; +use axum::{ + Json, Router, + extract::{Query, State}, + http::{StatusCode, header}, + response::IntoResponse, + routing::{get, post}, +}; +use axum_extra::extract::cookie::{Cookie, Key, SignedCookieJar}; +use rand::RngCore; +use serde::Deserialize; + +use crate::admin::auth::UserAuth; +use crate::auth::COOKIE_NAME; +use crate::auth::middleware::Claims; +use crate::event_log::{EventLog, Severity, log_event}; +use crate::service_identity::{self, IdentityMode}; +use crate::{AppState, error::AppError}; + +fn is_pds_session_expired(err: &impl std::fmt::Display) -> bool { + let msg = err.to_string(); + msg.contains("invalid_token") || msg.contains("expired") || msg.contains("revoked") +} + +fn pds_reauth_error() -> AppError { + AppError::Auth( + "Your PDS session has expired or been revoked. \ + Use the Re-authenticate button on the Service Identity page to sign in again." + .into(), + ) +} + +async fn require_setup_incomplete(state: &AppState) -> Result<(), AppError> { + let status = service_identity::get_setup_status(&state.db, state.db_backend).await?; + if status.setup_complete { + return Err(AppError::Forbidden("setup is already complete".into())); + } + Ok(()) +} + +pub fn routes() -> Router { + Router::new() + .route("/status", get(status)) + .route("/identity", post(set_identity)) + .route("/plc/register", post(plc_register)) + .route("/plc/request", post(plc_request)) + .route("/plc/submit", post(plc_submit)) + .route("/complete", post(complete)) + .route("/rotation-key", get(export_rotation_key)) + .route("/resolve", get(resolve_identity)) + .route("/attach-auth/confirm", post(attach_auth_confirm)) +} + +async fn status( + _auth: Claims, + State(state): State, +) -> Result, AppError> { + let status = service_identity::get_setup_status(&state.db, state.db_backend).await?; + Ok(Json(status)) +} + +#[derive(Debug, Deserialize)] +struct SetIdentityRequest { + mode: String, + attached_account_did: Option, +} + +#[derive(Debug, Deserialize)] +struct PlcSubmitBody { + token: String, +} + +async fn set_identity( + _auth: UserAuth, + State(state): State, + Json(body): Json, +) -> Result { + require_setup_incomplete(&state).await?; + let mode = IdentityMode::parse(&body.mode) + .ok_or_else(|| AppError::BadRequest(format!("invalid identity mode: {}", body.mode)))?; + + let (did, signing_key_enc, rotation_key_enc, attached_account_did) = match &mode { + IdentityMode::DidWeb => { + let signing_key_enc = generate_encrypted_signing_key(&state)?; + + (None::, Some(signing_key_enc), None, None) + } + + IdentityMode::DidPlc => { + let signing_key_enc = generate_encrypted_signing_key(&state)?; + let rotation_key_enc = generate_encrypted_signing_key(&state)?; + + (None, Some(signing_key_enc), Some(rotation_key_enc), None) + } + + IdentityMode::AttachAccount => { + let attached = body.attached_account_did.clone(); + (None, None, None, attached) + } + + IdentityMode::NotExposed => (None, None, None, None), + }; + + service_identity::upsert_identity( + &state.db, + state.db_backend, + &mode, + did.as_deref(), + signing_key_enc.as_deref(), + rotation_key_enc.as_deref(), + attached_account_did.as_deref(), + ) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +fn generate_encrypted_signing_key(state: &AppState) -> Result { + use base64::Engine; + use p256::ecdsa::SigningKey; + + let mut rng_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut rng_bytes); + + // Validate the key bytes produce a valid signing key + SigningKey::from_bytes((&rng_bytes[..]).into()) + .map_err(|e| AppError::Internal(format!("failed to generate signing key: {e}")))?; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + let encrypted = crate::plugin::encryption::encrypt(encryption_key, &rng_bytes) + .map_err(|e| AppError::Internal(format!("failed to encrypt signing key: {e}")))?; + + Ok(base64::engine::general_purpose::STANDARD.encode(&encrypted)) +} + +#[derive(Debug, serde::Serialize)] +struct PlcRegisterResponse { + did: String, +} + +/// Register a new did:plc identity by creating and submitting a genesis operation +/// to the PLC directory. +/// +/// This endpoint: +/// 1. Validates the identity mode is did_plc +/// 2. Decrypts the signing and rotation keys from the database +/// 3. Builds a PLC genesis operation with service entries +/// 4. Signs the operation with the rotation key +/// 5. Derives the DID from the signed operation +/// 6. Submits the signed operation to the PLC directory +/// 7. Updates the service_identity row with the new DID +async fn plc_register( + _auth: UserAuth, + State(state): State, +) -> Result, AppError> { + require_setup_incomplete(&state).await?; + let identity = service_identity::get_identity(&state.db, state.db_backend).await?; + let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; + + if identity.mode != IdentityMode::DidPlc { + return Err(AppError::BadRequest( + "PLC registration only supported for did_plc mode".into(), + )); + } + + if identity.did.is_some() { + return Err(AppError::Conflict( + "DID already registered for this identity".into(), + )); + } + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + // Decrypt signing key + let signing_key_enc = identity + .signing_key_enc + .as_ref() + .ok_or_else(|| AppError::Internal("no signing key stored".into()))?; + let signing_key_bytes = crate::plc::decrypt_key(signing_key_enc, encryption_key)?; + let signing_key_did = crate::plc::private_key_to_did_key(&signing_key_bytes)?; + + // Decrypt rotation key + let sql = crate::db::adapt_sql( + "SELECT rotation_key_enc FROM service_identity WHERE id = 1", + state.db_backend, + ); + let row: Option<(Option,)> = sqlx::query_as(&sql) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch rotation key: {e}")))?; + let rotation_key_enc = row + .and_then(|(k,)| k) + .ok_or_else(|| AppError::Internal("no rotation key stored".into()))?; + let rotation_key_bytes = crate::plc::decrypt_key(&rotation_key_enc, encryption_key)?; + let rotation_key_did = crate::plc::private_key_to_did_key(&rotation_key_bytes)?; + + let rotation_signing_key = + p256::ecdsa::SigningKey::from_bytes(rotation_key_bytes.as_slice().into()) + .map_err(|e| AppError::Internal(format!("invalid rotation key: {e}")))?; + + // Build service entries from the database + let entries = crate::service_entries::list_entries(&state.db, state.db_backend).await?; + let public_url = &state.config.public_url; + let service_entries: Vec<(String, String, String)> = entries + .iter() + .map(|e| { + let key = e.fragment_id.trim_start_matches('#').to_string(); + (key, e.service_type.clone(), public_url.clone()) + }) + .collect(); + + let params = crate::plc::PlcGenesisParams { + rotation_key_did_key: rotation_key_did, + signing_key_did_key: signing_key_did, + service_entries, + }; + + // Build, sign, derive DID, and submit + let unsigned = crate::plc::build_unsigned_genesis(¶ms); + let signed = crate::plc::sign_operation(&unsigned, &rotation_signing_key)?; + let did = crate::plc::derive_did(&signed)?; + + crate::plc::submit_genesis(&state.http, &state.config.plc_url, &did, &signed).await?; + + // Update service_identity with the newly registered DID + service_identity::upsert_identity( + &state.db, + state.db_backend, + &IdentityMode::DidPlc, + Some(&did), + Some(signing_key_enc), + Some(&rotation_key_enc), + None, + ) + .await?; + + tracing::info!(did = %did, "PLC identity registered"); + + Ok(Json(PlcRegisterResponse { did })) +} + +async fn plc_request( + _auth: UserAuth, + State(state): State, +) -> Result { + require_setup_incomplete(&state).await?; + let identity = service_identity::get_identity(&state.db, state.db_backend).await?; + let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; + + let account_did = match identity.mode { + IdentityMode::AttachAccount => { + let sql = crate::db::adapt_sql( + "SELECT attached_account_did FROM service_identity WHERE id = 1", + state.db_backend, + ); + let row: Option<(Option,)> = sqlx::query_as(&sql) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch identity: {e}")))?; + row.and_then(|(did,)| did) + .ok_or_else(|| AppError::BadRequest("no attached account DID configured".into()))? + } + _ => { + return Err(AppError::BadRequest( + "PLC flow only supported for attach_account mode".into(), + )); + } + }; + + // Restore OAuth session for the attached account + let session = crate::repo::session::get_oauth_session(&state, &account_did) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + e + })?; + let agent = Agent::new(session); + + // Request PLC operation signature — sends confirmation code to account's email + agent + .api + .com + .atproto + .identity + .request_plc_operation_signature() + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("requestPlcOperationSignature failed: {e}")) + })?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn plc_submit( + _auth: UserAuth, + State(state): State, + Json(body): Json, +) -> Result { + require_setup_incomplete(&state).await?; + let identity = service_identity::get_identity(&state.db, state.db_backend).await?; + let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; + + let account_did = match identity.mode { + IdentityMode::AttachAccount => { + let sql = crate::db::adapt_sql( + "SELECT attached_account_did FROM service_identity WHERE id = 1", + state.db_backend, + ); + let row: Option<(Option,)> = sqlx::query_as(&sql) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch identity: {e}")))?; + row.and_then(|(did,)| did) + .ok_or_else(|| AppError::BadRequest("no attached account DID configured".into()))? + } + _ => { + return Err(AppError::BadRequest( + "PLC flow only supported for attach_account mode".into(), + )); + } + }; + + let session = crate::repo::session::get_oauth_session(&state, &account_did) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + e + })?; + let agent = Agent::new(session); + + // Fetch current PLC operation state + let plc_url = state.config.plc_url.trim_end_matches('/'); + let last_op = state + .http + .get(format!("{}/{}/log/last", plc_url, account_did)) + .send() + .await + .map_err(|e| AppError::Internal(format!("failed to fetch PLC log: {e}")))? + .json::() + .await + .map_err(|e| AppError::Internal(format!("failed to parse PLC log: {e}")))?; + + // Preserve existing fields + let rotation_keys: Vec = last_op["rotationKeys"] + .as_array() + .ok_or_else(|| AppError::Internal("no rotationKeys in PLC operation".into()))? + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + let also_known_as: Vec = last_op["alsoKnownAs"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(); + + // Build services: merge existing + add our service entries + let mut services_map = last_op["services"].as_object().cloned().unwrap_or_default(); + + let entries = crate::service_entries::list_entries(&state.db, state.db_backend).await?; + let public_url = &state.config.public_url; + for entry in &entries { + let key = entry.fragment_id.trim_start_matches('#').to_string(); + services_map.insert( + key, + serde_json::json!({ + "type": entry.service_type, + "endpoint": public_url + }), + ); + } + + let services: Unknown = serde_json::from_value(serde_json::Value::Object(services_map)) + .map_err(|e| AppError::Internal(format!("failed to build services Unknown: {e}")))?; + + // Preserve existing verification methods + let vm_map = last_op["verificationMethods"] + .as_object() + .cloned() + .unwrap_or_default(); + let verification_methods: Unknown = serde_json::from_value(serde_json::Value::Object(vm_map)) + .map_err(|e| { + AppError::Internal(format!("failed to build verification methods Unknown: {e}")) + })?; + + // Sign the PLC operation via the user's PDS + use atrium_api::com::atproto::identity::sign_plc_operation; + let sign_result = agent + .api + .com + .atproto + .identity + .sign_plc_operation( + sign_plc_operation::InputData { + token: Some(body.token), + services: Some(services), + verification_methods: Some(verification_methods), + also_known_as: Some(also_known_as), + rotation_keys: Some(rotation_keys), + } + .into(), + ) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("signPlcOperation failed: {e}")) + })?; + + // Submit the signed operation + use atrium_api::com::atproto::identity::submit_plc_operation; + agent + .api + .com + .atproto + .identity + .submit_plc_operation( + submit_plc_operation::InputData { + operation: sign_result.operation.clone(), + } + .into(), + ) + .await + .map_err(|e| { + if is_pds_session_expired(&e) { + return pds_reauth_error(); + } + AppError::Internal(format!("submitPlcOperation failed: {e}")) + })?; + + // Update service_identity with the account's DID + service_identity::upsert_identity( + &state.db, + state.db_backend, + &IdentityMode::AttachAccount, + Some(&account_did), + None, + None, + Some(&account_did), + ) + .await?; + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct AttachAuthConfirmBody { + original_did: String, +} + +/// Restore the admin's session cookie after the attach-account OAuth flow. +/// +/// After the admin authenticates as the attached account (via the regular +/// `/auth/login` flow), the session cookie holds the attached account's DID. +/// This endpoint: +/// 1. Reads the current session (attached account DID) and verifies it matches +/// the `attached_account_did` stored in service_identity. +/// 2. Restores the admin's cookie to `original_did`. +/// +/// The attached account's OAuth session remains stored in the database for +/// use by the subsequent PLC request/submit flow. +async fn attach_auth_confirm( + State(state): State, + jar: SignedCookieJar, + Json(body): Json, +) -> Result<(SignedCookieJar, StatusCode), AppError> { + // Verify the current session is for the attached account + let current_cookie = jar + .get(COOKIE_NAME) + .ok_or_else(|| AppError::Auth("no session cookie present".into()))?; + let raw = current_cookie.value().to_string(); + let current_did = raw.split('\n').next().unwrap_or(&raw).to_string(); + + // Verify the current DID matches the configured attached_account_did + let sql = crate::db::adapt_sql( + "SELECT attached_account_did FROM service_identity WHERE id = 1", + state.db_backend, + ); + let row: Option<(Option,)> = sqlx::query_as(&sql) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch identity: {e}")))?; + let attached_did = row + .and_then(|(did,)| did) + .ok_or_else(|| AppError::BadRequest("no attached account configured".into()))?; + + if current_did != attached_did { + return Err(AppError::Auth(format!( + "current session DID '{}' does not match attached account DID '{}'", + current_did, attached_did + ))); + } + + // Restore the admin's cookie + let original_did = body.original_did.trim().to_string(); + if original_did.is_empty() || !original_did.starts_with("did:") { + return Err(AppError::BadRequest("invalid original_did".into())); + } + + // Verify the original DID is a known admin user + let user_exists: Option<(i32,)> = sqlx::query_as(&crate::db::adapt_sql( + "SELECT 1 FROM users WHERE did = ?", + state.db_backend, + )) + .bind(&original_did) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; + + if user_exists.is_none() { + return Err(AppError::Auth("original_did is not a known user".into())); + } + + let secure = state.config.public_url.starts_with("https://"); + let same_site = if secure { + axum_extra::extract::cookie::SameSite::None + } else { + axum_extra::extract::cookie::SameSite::Lax + }; + let mut session_cookie = Cookie::new(COOKIE_NAME, original_did); + session_cookie.set_path("/"); + session_cookie.set_http_only(true); + session_cookie.set_same_site(same_site); + session_cookie.set_secure(secure); + + let jar = jar.add(session_cookie); + + Ok((jar, StatusCode::NO_CONTENT)) +} + +async fn export_rotation_key( + _auth: UserAuth, + State(state): State, +) -> Result { + require_setup_incomplete(&state).await?; + use base64::Engine; + + let identity = service_identity::get_identity(&state.db, state.db_backend).await?; + let identity = identity.ok_or_else(|| AppError::BadRequest("no identity configured".into()))?; + + if identity.mode != IdentityMode::DidPlc { + return Err(AppError::BadRequest( + "rotation key export only supported for did_plc mode".into(), + )); + } + + let sql = crate::db::adapt_sql( + "SELECT rotation_key_enc FROM service_identity WHERE id = 1", + state.db_backend, + ); + let row: Option<(Option,)> = sqlx::query_as(&sql) + .fetch_optional(&state.db) + .await + .map_err(|e| AppError::Internal(format!("failed to fetch rotation key: {e}")))?; + + let enc_b64 = row + .and_then(|(k,)| k) + .ok_or_else(|| AppError::Internal("no rotation key stored".into()))?; + + let encrypted = base64::engine::general_purpose::STANDARD + .decode(&enc_b64) + .map_err(|e| AppError::Internal(format!("failed to decode rotation key: {e}")))?; + + let encryption_key = state + .config + .token_encryption_key + .as_ref() + .ok_or_else(|| AppError::Internal("TOKEN_ENCRYPTION_KEY not configured".into()))?; + + let key_bytes = crate::plugin::encryption::decrypt(encryption_key, &encrypted) + .map_err(|e| AppError::Internal(format!("failed to decrypt rotation key: {e}")))?; + + Ok(( + [ + (header::CONTENT_TYPE, "application/octet-stream"), + ( + header::CONTENT_DISPOSITION, + "attachment; filename=\"rotation-key.bin\"", + ), + ], + key_bytes, + )) +} + +async fn complete(_auth: UserAuth, State(state): State) -> Result { + require_setup_incomplete(&state).await?; + service_identity::mark_setup_complete(&state.db, state.db_backend).await?; + + log_event( + &state.db, + EventLog { + event_type: "setup.completed".to_string(), + severity: Severity::Info, + actor_did: None, + subject: None, + detail: serde_json::json!({}), + }, + state.db_backend, + ) + .await; + + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct ResolveQuery { + q: String, +} + +#[derive(Debug, serde::Serialize)] +struct ResolveResult { + did: String, + handle: Option, + display_name: Option, + avatar: Option, +} + +async fn resolve_identity( + _auth: UserAuth, + State(state): State, + Query(query): Query, +) -> Result>, AppError> { + let q = query.q.trim().to_string(); + if q.is_empty() { + return Ok(Json(vec![])); + } + + // If it's already a DID, resolve the profile directly + if q.starts_with("did:") { + match crate::profile::resolve_profile(&state.http, &state.config.plc_url, &q).await { + Ok(profile) => { + return Ok(Json(vec![ResolveResult { + did: profile.did, + handle: Some(profile.handle), + display_name: profile.display_name, + avatar: profile.avatar_url, + }])); + } + Err(_) => { + // Return the DID as-is if profile resolution fails + return Ok(Json(vec![ResolveResult { + did: q.to_string(), + handle: None, + display_name: None, + avatar: None, + }])); + } + } + } + + // Try to resolve the handle to a DID, then fetch the profile. + // AT Protocol handle resolution: check DNS TXT `_atproto.` for `did=`, + // or fall back to `https:///.well-known/atproto-did`. + let handle = q.trim_start_matches('@').to_string(); + let did = resolve_handle_to_did(&state.http, &handle).await; + + match did { + Some(did) => { + match crate::profile::resolve_profile(&state.http, &state.config.plc_url, &did).await { + Ok(profile) => Ok(Json(vec![ResolveResult { + did: profile.did, + handle: Some(profile.handle), + display_name: profile.display_name, + avatar: profile.avatar_url, + }])), + Err(_) => Ok(Json(vec![ResolveResult { + did, + handle: Some(handle), + display_name: None, + avatar: None, + }])), + } + } + None => Ok(Json(vec![])), + } +} + +/// Resolve an AT Protocol handle to a DID. +/// Tries HTTPS well-known first, then DNS TXT `_atproto.` fallback. +async fn resolve_handle_to_did(http: &reqwest::Client, handle: &str) -> Option { + // Try HTTPS well-known first (simpler, no DNS library needed here) + let url = format!("https://{}/.well-known/atproto-did", handle); + if let Ok(resp) = http.get(&url).send().await + && resp.status().is_success() + && let Ok(text) = resp.text().await + { + let did = text.trim().to_string(); + if did.starts_with("did:") { + return Some(did); + } + } + + // Try DNS TXT record `_atproto.` + use hickory_resolver::Resolver; + let lookup_name = format!("_atproto.{}.", handle); + if let Ok(resolver) = Resolver::builder_tokio().map(|b| b.build()) + && let Ok(txt_lookup) = resolver.txt_lookup(&lookup_name).await + { + let did = txt_lookup + .iter() + .flat_map(|txt| txt.txt_data().iter()) + .filter_map(|data| { + let s = std::str::from_utf8(data).ok()?; + s.strip_prefix("did=") + }) + .next() + .map(|s| s.to_string()); + if did.is_some() { + return did; + } + } + + None +} diff --git a/src/xrpc/mod.rs b/src/xrpc/mod.rs --- a/src/xrpc/mod.rs +++ b/src/xrpc/mod.rs @@ -154,29 +154,17 @@ .body(Body::from(bytes)) .unwrap()) } -/// Extract the API client key from the request for rate limiting. -/// -/// Every request must carry a client key. Returns an error when none is -/// found so the caller can reject the request with 401. -/// -/// Resolution order: -/// 1. Session cookie (`client_key` field in Claims) -/// 2. `X-Client-Key` header -/// 3. `client_key` query parameter +/// Find the client key from claims, headers, or query params. /// -/// Security validation (Origin / secret) is logged as warnings but does -/// not reject the request — the key is always used as the rate-limit -/// bucket regardless. -fn resolve_client_key( - state: &AppState, +/// Authenticated requests (claims present) must provide one — returns Err +/// if missing. Anonymous requests fall back to `"anonymous"`. +fn extract_client_key( claims: Option<&Claims>, parts: &Parts, query_params: &std::collections::HashMap, ) -> Result { - // 1. Try session cookie - let client_key = claims + let found = claims .and_then(|c| c.client_key().map(|k| k.to_string())) - // 2. Try X-Client-Key header .or_else(|| { parts .headers @@ -184,18 +172,30 @@ .get("x-client-key") .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()) }) - // 3. Try client_key query param .or_else(|| { query_params .get("client_key") .and_then(|v| v.as_str()) .map(|s| s.to_string()) - }) - .ok_or_else(|| { - AppError::Auth( - "Missing client identification. Provide an X-Client-Key header or client_key query parameter.".into(), - ) - })?; + }); + + match found { + Some(k) => Ok(k), + None if claims.is_some() => Err(AppError::Auth( + "Missing client identification. Provide an X-Client-Key header or client_key query parameter.".into(), + )), + None => Ok("anonymous".to_string()), + } +} + +/// Resolve the client key and run origin/secret validation. +fn resolve_client_key( + state: &AppState, + claims: Option<&Claims>, + parts: &Parts, + query_params: &std::collections::HashMap, +) -> Result { + let client_key = extract_client_key(claims, parts, query_params)?; // Log validation warnings but always return the key for rate limiting. if !state.rate_limiter.is_valid_client_key(&client_key) { @@ -258,9 +258,54 @@ parts: Parts, ) -> Result { let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); - let claims = xrpc_claims.identity; + let identity_claims = xrpc_claims.identity; + + // For service auth, synthesise Claims from the caller's DID so the + // query handler has an identity to work with. + let service_auth_claims_owned; + let claims: Option = if let Some(ref sa) = xrpc_claims.service_auth { + let has_access = crate::service_entries::check_access( + &state.db, + state.db_backend, + &sa.aud_fragment, + &method, + ) + .await?; - let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; + if !has_access { + crate::event_log::log_event( + &state.db, + crate::event_log::EventLog { + event_type: "service_auth.access_denied".to_string(), + severity: crate::event_log::Severity::Error, + actor_did: Some(sa.did.clone()), + subject: Some(method.clone()), + detail: serde_json::json!({ + "fragment": sa.aud_fragment, + "reason": "service entry not authorized for this XRPC" + }), + }, + state.db_backend, + ) + .await; + + return Err(AppError::Auth(format!( + "service '{}' is not authorized for '{}'", + sa.aud_fragment, method + ))); + } + + service_auth_claims_owned = Claims::internal(sa.did.clone()); + Some(service_auth_claims_owned) + } else { + identity_claims + }; + + let rate_key = if let Some(sa) = &xrpc_claims.service_auth { + format!("service:{}", sa.did) + } else { + resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)? + }; let lexicon = state.lexicons.get(&method).await; @@ -350,9 +395,16 @@ let raw_query = raw_query.unwrap_or_default(); let mut params = parse_query_params(&raw_query); let claims = xrpc_claims.identity; - let rate_key = resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)?; + let rate_key = if let Some(sa) = &xrpc_claims.service_auth { + format!("service:{}", sa.did) + } else { + resolve_client_key(&state, claims.as_ref(), &parts, ¶ms)? + }; - if claims.is_none() && xrpc_claims.space_credential.is_none() { + if claims.is_none() + && xrpc_claims.space_credential.is_none() + && xrpc_claims.service_auth.is_none() + { return Err(AppError::Auth( "XRPC procedures require DPoP authentication".into(), )); @@ -420,11 +472,23 @@ if let Some(ref param_schema) = lexicon.parameters { coerce_params(&mut params, param_schema); } - let claims = claims - .ok_or_else(|| AppError::Auth("XRPC procedures require DPoP authentication".into()))?; + // For service auth, synthesise Claims from the caller's DID so the + // procedure handler has an identity to work with. + let service_auth_claims_owned; + let (claims, sa_ref) = if let Some(ref sa) = xrpc_claims.service_auth { + service_auth_claims_owned = Claims::internal(sa.did.clone()); + (&service_auth_claims_owned, Some(sa)) + } else { + let c = claims + .ok_or_else(|| AppError::Auth("XRPC procedures require DPoP authentication".into()))?; + // Re-bind to a reference with matching lifetime + service_auth_claims_owned = c; + (&service_auth_claims_owned, None) + }; let mut response = - procedure::handle_procedure(&state, &method, &claims, &body, ¶ms, &lexicon).await?; + procedure::handle_procedure(&state, &method, claims, &body, ¶ms, &lexicon, sa_ref) + .await?; if let CheckResult::Allowed { remaining, limit, @@ -574,5 +638,80 @@ params.insert("limit".into(), Value::String("25".into())); let schema = json!({}); coerce_params(&mut params, &schema); assert_eq!(params["limit"], json!("25")); + } + + // ----------------------------------------------------------------------- + // extract_client_key + // ----------------------------------------------------------------------- + + fn empty_parts() -> axum::http::request::Parts { + let (parts, _) = axum::http::Request::builder() + .uri("/xrpc/test") + .body(()) + .unwrap() + .into_parts(); + parts + } + + #[test] + fn anonymous_request_gets_anonymous_rate_key() { + let parts = empty_parts(); + let params = HashMap::new(); + let result = extract_client_key(None, &parts, ¶ms); + assert_eq!(result.unwrap(), "anonymous"); + } + + #[test] + fn authenticated_request_without_client_key_is_rejected() { + let parts = empty_parts(); + let params = HashMap::new(); + let claims = crate::auth::Claims::new_for_test("did:plc:test".into()); + let result = extract_client_key(Some(&claims), &parts, ¶ms); + assert!(result.is_err()); + } + + #[test] + fn authenticated_request_with_client_key_in_claims() { + let parts = empty_parts(); + let params = HashMap::new(); + let claims = crate::auth::Claims::with_client_key("did:plc:test".into(), "hvc_abc".into()); + let result = extract_client_key(Some(&claims), &parts, ¶ms); + assert_eq!(result.unwrap(), "hvc_abc"); + } + + #[test] + fn x_client_key_header_used_for_anonymous() { + let (parts, _) = axum::http::Request::builder() + .uri("/xrpc/test") + .header("x-client-key", "hvc_from_header") + .body(()) + .unwrap() + .into_parts(); + let params = HashMap::new(); + let result = extract_client_key(None, &parts, ¶ms); + assert_eq!(result.unwrap(), "hvc_from_header"); + } + + #[test] + fn client_key_from_query_params() { + let parts = empty_parts(); + let mut params = HashMap::new(); + params.insert("client_key".into(), json!("hvc_from_query")); + let result = extract_client_key(None, &parts, ¶ms); + assert_eq!(result.unwrap(), "hvc_from_query"); + } + + #[test] + fn authenticated_request_uses_header_when_claims_lack_key() { + let (parts, _) = axum::http::Request::builder() + .uri("/xrpc/test") + .header("x-client-key", "hvc_fallback") + .body(()) + .unwrap() + .into_parts(); + let params = HashMap::new(); + let claims = crate::auth::Claims::new_for_test("did:plc:test".into()); + let result = extract_client_key(Some(&claims), &parts, ¶ms); + assert_eq!(result.unwrap(), "hvc_fallback"); } } diff --git a/src/xrpc/procedure.rs b/src/xrpc/procedure.rs --- a/src/xrpc/procedure.rs +++ b/src/xrpc/procedure.rs @@ -4,6 +4,7 @@ use serde_json::{Value, json}; use crate::AppState; use crate::auth::Claims; +use crate::auth::ServiceAuthClaims; use crate::db::{adapt_sql, now_rfc3339}; use crate::error::AppError; use crate::lexicon::ProcedureAction; @@ -17,10 +18,71 @@ claims: &Claims, input: &Value, params: &std::collections::HashMap, lexicon: &crate::lexicon::ParsedLexicon, + service_auth: Option<&ServiceAuthClaims>, ) -> Result { // Trigger-keyed dispatch: a script bound at `xrpc.procedure:` // overrides the default PDS-write flow. let trigger = format!("xrpc.procedure:{}", lexicon.id); + + // Service auth access and scope checks + if let Some(sa) = &service_auth { + let has_access = crate::service_entries::check_access( + &state.db, + state.db_backend, + &sa.aud_fragment, + method, + ) + .await?; + + if !has_access { + crate::event_log::log_event( + &state.db, + crate::event_log::EventLog { + event_type: "service_auth.access_denied".to_string(), + severity: crate::event_log::Severity::Error, + actor_did: Some(sa.did.clone()), + subject: Some(method.to_string()), + detail: serde_json::json!({ + "fragment": sa.aud_fragment, + "reason": "service entry not authorized for this XRPC" + }), + }, + state.db_backend, + ) + .await; + + return Err(AppError::Auth(format!( + "service '{}' is not authorized for '{}'", + sa.aud_fragment, method + ))); + } + + // Check token scope against outbound XRPCs declared by the script + let outbound_sql = adapt_sql( + "SELECT outbound_xrpcs FROM scripts WHERE id = ?", + state.db_backend, + ); + if let Ok(Some((Some(json_str),))) = sqlx::query_as::<_, (Option,)>(&outbound_sql) + .bind(&trigger) + .fetch_optional(&state.db) + .await + && let Ok(outbound_list) = serde_json::from_str::>(&json_str) + { + let token_scope: Vec<&str> = vec![&method]; + let missing: Vec<&String> = outbound_list + .iter() + .filter(|x| !token_scope.contains(&x.as_str())) + .collect(); + + if !missing.is_empty() { + let missing_list: Vec<&str> = missing.iter().map(|s| s.as_str()).collect(); + return Err(AppError::Auth(format!( + "this procedure calls additional XRPCs not covered by the token scope: {}", + missing_list.join(", ") + ))); + } + } + } if let Some(resolved) = crate::lua::resolve(state, &trigger).await { // Delegation guard preserved from origin/dev: scripts that run // under a `delegateDid` must come from a caller who is an diff --git a/tests/common/app.rs b/tests/common/app.rs --- a/tests/common/app.rs +++ b/tests/common/app.rs @@ -5,6 +5,8 @@ AtprotoLocalhostClientMetadata, DefaultHttpClient, KnownScope, OAuthClientConfig, OAuthResolverConfig, Scope, }; use axum::Router; +use axum::http::Request; +use base64::Engine as _; use happyview::config::Config; use happyview::db::{DatabaseBackend, adapt_sql, now_rfc3339}; use happyview::lexicon::LexiconRegistry; @@ -20,6 +22,7 @@ pub state: AppState, pub mock_server: MockServer, pub admin_did: String, pub admin_token: String, + _db_lock: Option, } impl TestApp { @@ -33,6 +36,7 @@ pub async fn new_with_registry_config( registry_config: happyview::plugin::official_registry::RegistryConfig, ) -> Self { + let _db_lock = db::acquire_test_lock().await; let pool = db::test_pool().await; let backend = db::test_backend(); db::truncate_all(&pool).await; @@ -172,7 +176,20 @@ backfill_events_tx: tokio::sync::broadcast::channel(16).0, verbose_event_logging: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), }; - let router = server::router(state.clone()).layer(axum::middleware::from_fn( + let router = Self::build_router(&state); + + Self { + router, + state, + mock_server, + admin_did, + _db_lock, + admin_token, + } + } + + fn build_router(state: &AppState) -> axum::Router { + server::router(state.clone()).layer(axum::middleware::from_fn( |mut req: axum::extract::Request, next: axum::middleware::Next| async move { if !req.headers().contains_key("host") { req.headers_mut() @@ -180,28 +197,24 @@ .insert("host", axum::http::HeaderValue::from_static("127.0.0.1")); } next.run(req).await }, - )); + )) + } - Self { - router, - state, - mock_server, - admin_did, - admin_token, - } + pub fn rebuild_router(&mut self) { + self.router = Self::build_router(&self.state); } pub async fn new_with_base_path(base_path: &str) -> Self { let mut app = Self::new().await; app.state.config.base_path = Some(base_path.to_string()); - app.router = server::router(app.state.clone()); + app.rebuild_router(); app } pub async fn new_with_encryption() -> Self { let mut app = Self::new().await; app.state.config.token_encryption_key = Some([0x42u8; 32]); - app.router = server::router(app.state.clone()); + app.rebuild_router(); app } @@ -260,6 +273,311 @@ /// Build a Cookie header that authenticates as the admin user. pub fn admin_cookie(&self) -> (axum::http::HeaderName, axum::http::HeaderValue) { crate::common::auth::admin_cookie_header(&self.admin_did, &self.state.cookie_key) + } + + /// Return a `Request::builder()` pre-configured with the admin auth cookie. + pub fn authed_request(&self) -> axum::http::request::Builder { + let cookie = self.admin_cookie(); + Request::builder().header(cookie.0, cookie.1) + } + + pub async fn setup_did_web(&mut self) -> String { + use p256::ecdsa::SigningKey; + use rand::RngCore; + + let encryption_key = [0x42u8; 32]; + self.state.config.token_encryption_key = Some(encryption_key); + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + let private_bytes = signing_key.to_bytes(); + let encrypted = happyview::plugin::encryption::encrypt(&encryption_key, &private_bytes) + .expect("encryption failed"); + let enc_b64 = base64::engine::general_purpose::STANDARD.encode(encrypted); + + let url = &self.state.config.public_url; + let host = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")) + .unwrap_or(url); + let did = format!("did:web:{}", host.replace(':', "%3A")); + + happyview::service_identity::upsert_identity( + &self.state.db, + self.state.db_backend, + &happyview::service_identity::IdentityMode::DidWeb, + None, + Some(&enc_b64), + None, + None, + ) + .await + .expect("failed to upsert service identity"); + + happyview::service_identity::mark_setup_complete(&self.state.db, self.state.db_backend) + .await + .expect("failed to mark setup complete"); + + self.rebuild_router(); + + did + } + + pub async fn create_service_entry( + &self, + fragment_id: &str, + service_type: &str, + access_mode: &str, + ) -> i64 { + let entry = happyview::service_entries::create_entry( + &self.state.db, + self.state.db_backend, + &happyview::service_entries::CreateServiceEntry { + fragment_id: fragment_id.to_string(), + service_type: service_type.to_string(), + }, + ) + .await + .expect("failed to create service entry"); + + if access_mode != "all" { + happyview::service_entries::update_entry( + &self.state.db, + self.state.db_backend, + entry.id, + &happyview::service_entries::UpdateServiceEntry { + fragment_id: None, + service_type: None, + access_mode: Some(access_mode.to_string()), + }, + ) + .await + .expect("failed to update service entry access mode"); + } + + entry.id + } + + pub async fn add_entry_xrpcs(&self, entry_id: i64, xrpcs: &[&str]) { + let xrpc_strings: Vec = xrpcs.iter().map(|s| s.to_string()).collect(); + happyview::service_entries::add_entry_xrpcs( + &self.state.db, + self.state.db_backend, + entry_id, + &xrpc_strings, + ) + .await + .expect("failed to add entry xrpcs"); + } + + pub async fn service_auth_jwt( + &self, + plc_store: &crate::common::plc::PlcStore, + issuer_did: &str, + instance_did: &str, + aud_fragment: &str, + ) -> String { + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use p256::ecdsa::{SigningKey, signature::Signer}; + use rand::RngCore; + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + let public_key = signing_key.verifying_key(); + let compressed = public_key.to_encoded_point(true); + + let did_doc = crate::common::plc::test_did_document(issuer_did, compressed.as_bytes()); + plc_store + .write() + .await + .insert(issuer_did.to_string(), did_doc); + + let header = serde_json::json!({"alg": "ES256"}); + let payload = serde_json::json!({ + "iss": issuer_did, + "aud": format!("{}{}", instance_did, aud_fragment), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64) + } + + pub async fn setup_not_exposed(&mut self) { + happyview::service_identity::upsert_identity( + &self.state.db, + self.state.db_backend, + &happyview::service_identity::IdentityMode::NotExposed, + None, + None, + None, + None, + ) + .await + .expect("failed to upsert not_exposed identity"); + + happyview::service_identity::mark_setup_complete(&self.state.db, self.state.db_backend) + .await + .expect("failed to mark setup complete"); + + self.rebuild_router(); + } + + pub async fn setup_did_plc(&mut self) -> String { + use p256::ecdsa::SigningKey; + use rand::RngCore; + + let encryption_key = [0x42u8; 32]; + self.state.config.token_encryption_key = Some(encryption_key); + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + let private_bytes = signing_key.to_bytes(); + let encrypted = happyview::plugin::encryption::encrypt(&encryption_key, &private_bytes) + .expect("encryption failed"); + let enc_b64 = base64::engine::general_purpose::STANDARD.encode(encrypted); + + let did = "did:plc:testinstance".to_string(); + + happyview::service_identity::upsert_identity( + &self.state.db, + self.state.db_backend, + &happyview::service_identity::IdentityMode::DidPlc, + Some(&did), + Some(&enc_b64), + None, + None, + ) + .await + .expect("failed to upsert did:plc identity"); + + happyview::service_identity::mark_setup_complete(&self.state.db, self.state.db_backend) + .await + .expect("failed to mark setup complete"); + + self.rebuild_router(); + + did + } + + pub async fn raw_service_auth_jwt( + &self, + plc_store: &crate::common::plc::PlcStore, + issuer_did: &str, + aud: &str, + exp: u64, + ) -> String { + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use p256::ecdsa::{SigningKey, signature::Signer}; + use rand::RngCore; + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + let public_key = signing_key.verifying_key(); + let compressed = public_key.to_encoded_point(true); + + let did_doc = crate::common::plc::test_did_document(issuer_did, compressed.as_bytes()); + plc_store + .write() + .await + .insert(issuer_did.to_string(), did_doc); + + let header = serde_json::json!({"alg": "ES256"}); + let payload = serde_json::json!({ + "iss": issuer_did, + "aud": aud, + "exp": exp, + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64) + } + + pub async fn custom_service_auth_jwt( + &self, + plc_store: &crate::common::plc::PlcStore, + issuer_did: &str, + header: serde_json::Value, + payload: serde_json::Value, + ) -> String { + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use p256::ecdsa::{SigningKey, signature::Signer}; + use rand::RngCore; + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + let public_key = signing_key.verifying_key(); + let compressed = public_key.to_encoded_point(true); + + let did_doc = crate::common::plc::test_did_document(issuer_did, compressed.as_bytes()); + plc_store + .write() + .await + .insert(issuer_did.to_string(), did_doc); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64) + } + + pub fn use_permissive_http_client(&mut self) { + self.state.http = reqwest::Client::builder() + .danger_accept_invalid_certs(true) + .build() + .expect("failed to build permissive http client"); + self.rebuild_router(); + } + + pub fn did_web_service_auth_jwt( + &self, + signing_key: &p256::ecdsa::SigningKey, + issuer_did: &str, + instance_did: &str, + aud_fragment: &str, + ) -> String { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use p256::ecdsa::signature::Signer; + + let header = serde_json::json!({"alg": "ES256"}); + let payload = serde_json::json!({ + "iss": issuer_did, + "aud": format!("{}{}", instance_did, aud_fragment), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64) } /// Install a fake plugin directly into the registry at the given version. diff --git a/tests/common/db.rs b/tests/common/db.rs --- a/tests/common/db.rs +++ b/tests/common/db.rs @@ -15,12 +15,40 @@ std::env::var("TEST_DATABASE_URL").expect("TEST_DATABASE_URL must be set for e2e tests"); DatabaseBackend::from_url(&url) } +/// Acquire a cross-process advisory lock via a dedicated Postgres connection pool. +/// The lock is held on a connection within the returned pool. When the pool is dropped, +/// the connection closes and the advisory lock is released. +/// For SQLite, returns None (no cross-process locking needed). +pub async fn acquire_test_lock() -> Option { + let url = std::env::var("TEST_DATABASE_URL").ok()?; + let backend = DatabaseBackend::from_url(&url); + + if !matches!(backend, DatabaseBackend::Postgres) { + return None; + } + + sqlx::any::install_default_drivers(); + + let lock_pool = sqlx::any::AnyPoolOptions::new() + .max_connections(1) + .connect(&url) + .await + .expect("failed to create advisory lock pool"); + + sqlx::query("SELECT pg_advisory_lock(42)") + .execute(&lock_pool) + .await + .expect("failed to acquire advisory lock"); + + Some(lock_pool) +} + pub async fn truncate_all(pool: &AnyPool) { let backend = test_backend(); match backend { DatabaseBackend::Postgres => { sqlx::query( - "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, scripts, dead_letter_scripts, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients, delegated_accounts, account_delegates RESTART IDENTITY CASCADE", + "TRUNCATE records, lexicons, backfill_jobs, users, user_permissions, api_keys, event_logs, script_variables, scripts, dead_letter_scripts, dead_letter_hooks, record_refs, labeler_subscriptions, labels, instance_settings, domains, dpop_sessions, dpop_keys, api_clients, delegated_accounts, account_delegates, service_identity, service_entries, service_entry_xrpcs RESTART IDENTITY CASCADE", ) .execute(pool) .await @@ -28,6 +56,9 @@ .expect("failed to truncate tables"); } DatabaseBackend::Sqlite => { let tables = [ + "service_entry_xrpcs", + "service_entries", + "service_identity", "account_delegates", "delegated_accounts", "dpop_sessions", diff --git a/tests/common/mod.rs b/tests/common/mod.rs --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -6,6 +6,10 @@ #[allow(dead_code, unused_imports)] pub mod db; #[allow(dead_code, unused_imports)] pub mod fixtures; +#[allow(dead_code, unused_imports)] +pub mod plc; +#[allow(dead_code, unused_imports)] +pub mod tls; #[allow(unused_macros)] macro_rules! require_db { diff --git a/tests/common/plc.rs b/tests/common/plc.rs new file mode 100644 --- /dev/null +++ b/tests/common/plc.rs @@ -0,0 +1,99 @@ +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use wiremock::matchers::method; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +pub type PlcStore = Arc>>; + +struct PlcGetResponder { + store: PlcStore, +} + +impl Respond for PlcGetResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let path = request.url.path(); + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + + if segments.is_empty() { + return ResponseTemplate::new(404); + } + + let did = segments[0]; + let store = self.store.clone(); + let did_owned = did.to_string(); + + if segments.len() >= 3 && segments[1] == "log" && segments[2] == "last" { + let store = futures::executor::block_on(store.read()); + return match store.get(&did_owned) { + Some(doc) => ResponseTemplate::new(200).set_body_json(doc.clone()), + None => ResponseTemplate::new(404), + }; + } + + let store = futures::executor::block_on(store.read()); + match store.get(&did_owned) { + Some(doc) => ResponseTemplate::new(200).set_body_json(doc.clone()), + None => ResponseTemplate::new(404), + } + } +} + +struct PlcPostResponder { + store: PlcStore, +} + +impl Respond for PlcPostResponder { + fn respond(&self, request: &Request) -> ResponseTemplate { + let path = request.url.path(); + let did = path.trim_start_matches('/').to_string(); + + if let Ok(body) = serde_json::from_slice::(&request.body) { + let store = self.store.clone(); + futures::executor::block_on(async { + store.write().await.insert(did, body); + }); + } + + ResponseTemplate::new(200) + } +} + +pub async fn setup_mock_plc(server: &MockServer) -> PlcStore { + let store: PlcStore = Arc::new(RwLock::new(HashMap::new())); + + Mock::given(method("GET")) + .respond_with(PlcGetResponder { + store: store.clone(), + }) + .mount(server) + .await; + + Mock::given(method("POST")) + .respond_with(PlcPostResponder { + store: store.clone(), + }) + .mount(server) + .await; + + store +} + +pub fn test_did_document(did: &str, public_key_bytes: &[u8]) -> Value { + let mut multikey = vec![0x80, 0x24]; + multikey.extend_from_slice(public_key_bytes); + let multibase_key = multibase::encode(multibase::Base::Base58Btc, &multikey); + + json!({ + "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], + "id": did, + "verificationMethod": [{ + "id": format!("{did}#atproto"), + "type": "Multikey", + "controller": did, + "publicKeyMultibase": multibase_key + }], + "service": [] + }) +} diff --git a/tests/common/tls.rs b/tests/common/tls.rs new file mode 100644 --- /dev/null +++ b/tests/common/tls.rs @@ -0,0 +1,93 @@ +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::task::JoinHandle; + +pub struct DidWebServer { + pub port: u16, + pub did: String, + _handle: JoinHandle<()>, +} + +impl DidWebServer { + pub fn issuer_did(&self) -> &str { + &self.did + } +} + +/// Start a TLS server that serves a DID document at `/.well-known/did.json`. +/// +/// `build_doc` receives the computed `did:web:localhost%3A{port}` DID and +/// returns the DID document to serve. This solves the chicken-and-egg problem +/// where the DID depends on the port. +pub async fn start_did_web_server( + build_doc: impl FnOnce(&str) -> serde_json::Value, +) -> DidWebServer { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let cert = rcgen::generate_simple_self_signed(vec!["localhost".into()]) + .expect("failed to generate self-signed cert"); + let cert_der = cert.cert.der().to_vec(); + let key_der = cert.key_pair.serialize_der(); + + let tls_config = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert( + vec![rustls::pki_types::CertificateDer::from(cert_der)], + rustls::pki_types::PrivateKeyDer::Pkcs8(key_der.into()), + ) + .expect("failed to build TLS config"); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("failed to bind TLS listener"); + let port = listener.local_addr().unwrap().port(); + let did = format!("did:web:localhost%3A{port}"); + + let did_doc = build_doc(&did); + let did_doc_bytes = serde_json::to_vec(&did_doc).unwrap(); + + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(tls_config)); + + let handle = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + continue; + }; + let acceptor = acceptor.clone(); + let body = did_doc_bytes.clone(); + + tokio::spawn(async move { + let Ok(mut tls) = acceptor.accept(stream).await else { + return; + }; + + let mut buf = vec![0u8; 4096]; + let _ = tls.read(&mut buf).await; + + let request = String::from_utf8_lossy(&buf); + let response = if request.contains("/.well-known/did.json") { + format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len(), + ) + } else { + "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .to_string() + }; + + let _ = tls.write_all(response.as_bytes()).await; + if request.contains("/.well-known/did.json") { + let _ = tls.write_all(&body).await; + } + let _ = tls.shutdown().await; + }); + } + }); + + DidWebServer { + port, + did, + _handle: handle, + } +} diff --git a/tests/e2e_admin_service_entries.rs b/tests/e2e_admin_service_entries.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_admin_service_entries.rs @@ -0,0 +1,924 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; +use common::plc; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +// --------------------------------------------------------------------------- +// Service entry CRUD via admin endpoints +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn create_list_update_delete_service_entry() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + // CREATE + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries") + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "fragment_id": "#chess", + "service_type": "ChessAppView" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::CREATED); + let created = json_body(resp).await; + let entry_id = created["id"].as_i64().unwrap(); + assert_eq!(created["fragment_id"], "#chess"); + assert_eq!(created["service_type"], "ChessAppView"); + assert_eq!(created["access_mode"], "all"); + + // LIST + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-entries") + .header(cookie.0.clone(), cookie.1.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let list = json_body(resp).await; + let entries = list.as_array().unwrap(); + assert!(entries.iter().any(|e| e["id"].as_i64() == Some(entry_id))); + + // UPDATE + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/service-entries/{}", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "access_mode": "specific" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // DELETE + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/admin/service-entries/{}", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Verify deletion + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-entries") + .header(cookie.0.clone(), cookie.1.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let list = json_body(resp).await; + let entries = list.as_array().unwrap(); + assert!(!entries.iter().any(|e| e["id"].as_i64() == Some(entry_id))); +} + +#[tokio::test] +#[serial] +async fn delete_nonexistent_entry_returns_404() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/admin/service-entries/99999") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn update_entry_with_invalid_access_mode_returns_400() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let entry_id = app + .create_service_entry("#chess", "ChessAppView", "all") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/service-entries/{}", entry_id)) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "access_mode": "invalid_mode" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "invalid access_mode should return 400" + ); +} + +// --------------------------------------------------------------------------- +// XRPC junction table via admin endpoints +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn add_list_remove_entry_xrpcs() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let entry_id = app + .create_service_entry("#chess", "ChessAppView", "specific") + .await; + + // ADD xrpcs + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "lexicon_ids": ["games.example.listGames", "games.example.getGame"] + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // LIST xrpcs + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let xrpcs = json_body(resp).await; + let list = xrpcs.as_array().unwrap(); + assert_eq!(list.len(), 2); + assert!(list.contains(&json!("games.example.getGame"))); + assert!(list.contains(&json!("games.example.listGames"))); + + // REMOVE one xrpc + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "lexicon_ids": ["games.example.getGame"] + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Verify removal + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let xrpcs = json_body(resp).await; + let list = xrpcs.as_array().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0], "games.example.listGames"); +} + +// --------------------------------------------------------------------------- +// Reverse lookup: services_for_lexicon +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn lexicon_services_reverse_lookup() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let id_all = app + .create_service_entry("#chess", "ChessAppView", "all") + .await; + let id_specific = app + .create_service_entry("#checkers", "CheckersAppView", "specific") + .await; + app.add_entry_xrpcs(id_specific, &["games.example.listGames"]) + .await; + + // Both should appear for games.example.listGames (one via access_mode=all, one via junction) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/lexicons/games.example.listGames/services") + .header(cookie.0.clone(), cookie.1.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let services = json_body(resp).await; + let list = services.as_array().unwrap(); + assert_eq!(list.len(), 2); + let ids: Vec = list.iter().filter_map(|e| e["id"].as_i64()).collect(); + assert!(ids.contains(&id_all)); + assert!(ids.contains(&id_specific)); + + // Only #chess (access_mode=all) should appear for a random XRPC + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/lexicons/games.example.unrelated/services") + .header(cookie.0.clone(), cookie.1.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let services = json_body(resp).await; + let list = services.as_array().unwrap(); + assert_eq!(list.len(), 1); + assert_eq!(list[0]["id"].as_i64().unwrap(), id_all); +} + +// --------------------------------------------------------------------------- +// Update entry with empty body — short-circuit path +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn update_entry_with_empty_body() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let entry_id = app + .create_service_entry("#empty", "EmptyUpdate", "all") + .await; + + // Send an update with no fields — should succeed (no-op update) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/service-entries/{}", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&json!({})).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::NO_CONTENT, + "empty update body should succeed" + ); + + // Verify entry is unchanged + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-entries") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let list = json_body(resp).await; + let entries = list.as_array().unwrap(); + let entry = entries + .iter() + .find(|e| e["id"].as_i64() == Some(entry_id)) + .unwrap(); + assert_eq!(entry["service_type"], "EmptyUpdate"); + assert_eq!(entry["access_mode"], "all"); +} + +// --------------------------------------------------------------------------- +// Non-admin permission checks +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unauthenticated_list_entries_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-entries") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated request to admin endpoint should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_create_entry_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "fragment_id": "#noauth", + "service_type": "NoAuth" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated POST to admin endpoint should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_delete_entry_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/admin/service-entries/1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated DELETE to admin endpoint should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_sync_plc_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries/sync-plc") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated POST to sync-plc should be rejected, got {}", + resp.status() + ); +} + +// --------------------------------------------------------------------------- +// Update nonexistent entry returns 404 +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn update_nonexistent_entry_returns_404() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-entries/99999") + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "access_mode": "specific" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "updating a nonexistent entry should return 404" + ); +} + +// --------------------------------------------------------------------------- +// XRPC idempotency — adding the same NSID twice +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn add_entry_xrpcs_idempotent() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let entry_id = app + .create_service_entry("#chess", "ChessAppView", "specific") + .await; + + let add_body = serde_json::to_vec(&json!({ + "lexicon_ids": ["games.example.listGames"] + })) + .unwrap(); + + // Add once + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from(add_body.clone())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Add the same NSID again — should succeed (ON CONFLICT DO NOTHING) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from(add_body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NO_CONTENT, + "adding the same xrpc twice should succeed idempotently" + ); + + // Verify only one entry exists + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let xrpcs = json_body(resp).await; + assert_eq!( + xrpcs.as_array().unwrap().len(), + 1, + "should have exactly one entry after duplicate add" + ); +} + +// --------------------------------------------------------------------------- +// Unauthenticated access to remaining endpoints +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unauthenticated_update_entry_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-entries/1") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"access_mode": "all"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated PUT should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_list_xrpcs_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-entries/1/xrpcs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated GET xrpcs should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_add_xrpcs_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries/1/xrpcs") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"lexicon_ids": ["test.foo.bar"]})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated POST xrpcs should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_remove_xrpcs_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/admin/service-entries/1/xrpcs") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"lexicon_ids": ["test.foo.bar"]})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated DELETE xrpcs should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_lexicon_services_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/lexicons/test.foo.bar/services") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated lexicon services lookup should be rejected, got {}", + resp.status() + ); +} + +// --------------------------------------------------------------------------- +// PLC sync — did_plc mode +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn sync_plc_updates_did_document() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_plc().await; + + // Generate and store a rotation key (setup_did_plc only stores a signing key) + let encryption_key = [0x42u8; 32]; + + let mut rotation_key_bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rng(), &mut rotation_key_bytes); + let _rotation_key = + p256::ecdsa::SigningKey::from_bytes((&rotation_key_bytes[..]).into()).unwrap(); + let encrypted = happyview::plugin::encryption::encrypt(&encryption_key, &rotation_key_bytes) + .expect("encryption failed"); + let rotation_key_enc = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &encrypted); + + let sql = happyview::db::adapt_sql( + "UPDATE service_identity SET rotation_key_enc = ? WHERE id = 1", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&rotation_key_enc) + .execute(&app.state.db) + .await + .expect("failed to store rotation key"); + + // Build a genesis-like PLC document for the mock + let rotation_did_key = happyview::plc::private_key_to_did_key(&rotation_key_bytes).unwrap(); + + // Compute the signing key's did:key from the stored identity + let identity = happyview::service_identity::get_identity(&app.state.db, app.state.db_backend) + .await + .unwrap() + .unwrap(); + let signing_key_bytes = + happyview::plc::decrypt_key(identity.signing_key_enc.as_ref().unwrap(), &encryption_key) + .unwrap(); + let signing_did_key = happyview::plc::private_key_to_did_key(&signing_key_bytes).unwrap(); + + let genesis_doc = json!({ + "type": "plc_operation", + "rotationKeys": [&rotation_did_key], + "verificationMethods": { + "atproto": &signing_did_key, + }, + "alsoKnownAs": [], + "services": {}, + "prev": null, + "cid": "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + + plc_store.write().await.insert(did.clone(), genesis_doc); + + // Create a service entry + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + // POST sync-plc + let cookie = app.admin_cookie(); + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries/sync-plc") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::NO_CONTENT, + "sync-plc should return 204" + ); + + // Verify the PLC mock received the updated document + let store = plc_store.read().await; + let updated = store.get(&did).expect("PLC store should have the DID"); + let services = updated["services"] + .as_object() + .expect("services should exist"); + assert!( + services.contains_key("chess"), + "services should contain the chess entry" + ); + assert_eq!(updated["services"]["chess"]["type"], "ChessAppView"); +} + +#[tokio::test] +#[serial] +async fn sync_plc_rejects_non_plc_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + let cookie = app.admin_cookie(); + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries/sync-plc") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "sync-plc should reject non-plc mode" + ); +} diff --git a/tests/e2e_admin_service_identity.rs b/tests/e2e_admin_service_identity.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_admin_service_identity.rs @@ -0,0 +1,205 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +// --------------------------------------------------------------------------- +// GET /admin/service-identity +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_identity_returns_null_when_not_configured() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-identity") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert!(body.is_null(), "expected null when no identity configured"); +} + +#[tokio::test] +#[serial] +async fn get_identity_returns_identity_after_setup() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + let cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-identity") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["mode"], "did_web"); + assert!( + body["did"].is_null(), + "did:web derives DID from host, not stored" + ); + assert_eq!(body["setup_complete"], true); +} + +// --------------------------------------------------------------------------- +// PUT /admin/service-identity +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn update_identity_changes_mode() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-identity") + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "mode": "not_exposed" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Verify the mode was persisted + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-identity") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = json_body(resp).await; + assert_eq!(body["mode"], "not_exposed"); +} + +#[tokio::test] +#[serial] +async fn update_identity_rejects_invalid_mode() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-identity") + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "mode": "invalid_mode" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +#[serial] +async fn get_identity_requires_auth() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-identity") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn update_identity_requires_auth() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "mode": "not_exposed" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} diff --git a/tests/e2e_proxy_config.rs b/tests/e2e_proxy_config.rs --- a/tests/e2e_proxy_config.rs +++ b/tests/e2e_proxy_config.rs @@ -178,6 +178,41 @@ } #[tokio::test] #[serial] +async fn put_and_get_blocklist() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "blocklist", + "nsids": ["com.blocked.feed.*"] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["mode"], "blocklist"); + assert_eq!(json["nsids"], json!(["com.blocked.feed.*"])); +} + +#[tokio::test] +#[serial] async fn requires_auth() { common::require_db!(); let app = TestApp::new().await; diff --git a/tests/e2e_service_identity.rs b/tests/e2e_service_identity.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_service_identity.rs @@ -0,0 +1,2205 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; +use common::fixtures; +use common::plc; +use common::tls; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +fn admin_post( + uri: &str, + cookie: (axum::http::HeaderName, axum::http::HeaderValue), + body: &Value, +) -> Request { + Request::builder() + .method("POST") + .uri(uri) + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() +} + +async fn seed_query_lexicon(app: &TestApp) { + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + app.admin_cookie(), + &json!({ + "lexicon_json": fixtures::game_record_lexicon(), + "backfill": false + }), + )) + .await + .unwrap(); + + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + app.admin_cookie(), + &json!({ + "lexicon_json": fixtures::list_games_query_lexicon(), + "target_collection": "games.gamesgamesgamesgames.game" + }), + )) + .await + .unwrap(); +} + +async fn seed_procedure_lexicon(app: &TestApp) { + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + app.admin_cookie(), + &json!({ + "lexicon_json": fixtures::game_record_lexicon(), + "backfill": false + }), + )) + .await + .unwrap(); + + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + app.admin_cookie(), + &json!({ + "lexicon_json": fixtures::create_game_procedure_lexicon(), + "target_collection": "games.gamesgamesgamesgames.game" + }), + )) + .await + .unwrap(); +} + +async fn seed_procedure_script(app: &TestApp, body: &str) { + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/scripts", + app.admin_cookie(), + &json!({ + "id": "xrpc.procedure:games.gamesgamesgamesgames.createGame", + "script_type": "lua", + "body": body, + "description": "test procedure" + }), + )) + .await + .unwrap(); + + assert!( + resp.status().is_success(), + "seed_procedure_script failed with status {}", + resp.status(), + ); +} + +// --------------------------------------------------------------------------- +// Setup status endpoint +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_status_unconfigured() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["identity_configured"], false); + assert!(body["identity_mode"].is_null()); +} + +#[tokio::test] +#[serial] +async fn setup_status_after_did_web() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["identity_configured"], true); + assert_eq!(body["identity_mode"], "did_web"); +} + +#[tokio::test] +#[serial] +async fn setup_status_after_not_exposed() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_not_exposed().await; + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["identity_mode"], "not_exposed"); +} + +// --------------------------------------------------------------------------- +// DID document generation +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn did_doc_returns_404_when_no_identity() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/.well-known/did.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn did_doc_empty_services() { + common::require_db!(); + let mut app = TestApp::new().await; + let did = app.setup_did_web().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/.well-known/did.json") + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let doc = json_body(resp).await; + assert_eq!(doc["id"], did); + assert!(!doc["verificationMethod"].as_array().unwrap().is_empty()); + assert_eq!(doc["service"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +#[serial] +async fn did_doc_with_entries() { + common::require_db!(); + let mut app = TestApp::new().await; + let _did = app.setup_did_web().await; + + let _id1 = app + .create_service_entry("#chess", "ChessAppView", "all") + .await; + let _id2 = app + .create_service_entry("#checkers", "CheckersAppView", "all") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/.well-known/did.json") + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let doc = json_body(resp).await; + let services = doc["service"].as_array().unwrap(); + assert_eq!(services.len(), 2); + assert_eq!(services[0]["id"], "#chess"); + assert_eq!(services[0]["type"], "ChessAppView"); + assert_eq!(services[1]["id"], "#checkers"); + + happyview::service_entries::delete_entry(&app.state.db, app.state.db_backend, _id1) + .await + .unwrap(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/.well-known/did.json") + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let doc = json_body(resp).await; + let services = doc["service"].as_array().unwrap(); + assert_eq!(services.len(), 1); + assert_eq!(services[0]["id"], "#checkers"); +} + +// --------------------------------------------------------------------------- +// Service auth — queries +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn service_auth_query_allowed() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:caller123", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +#[serial] +async fn service_auth_query_denied() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#chess", "ChessAppView", "specific") + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:caller456", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = json_body(resp).await; + assert!(body["error"].as_str().unwrap().contains("not authorized")); +} + +#[tokio::test] +#[serial] +async fn service_auth_specific_xrpc_allowed() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + let entry_id = app + .create_service_entry("#chess", "ChessAppView", "specific") + .await; + app.add_entry_xrpcs(entry_id, &["games.gamesgamesgamesgames.listGames"]) + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:caller789", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// Service auth — procedures +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn service_auth_procedure_allowed() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + let entry_id = app + .create_service_entry("#chess", "ChessAppView", "specific") + .await; + app.add_entry_xrpcs(entry_id, &["games.gamesgamesgamesgames.createGame"]) + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:procallowed", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); +} + +#[tokio::test] +#[serial] +async fn service_auth_procedure_denied() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + app.create_service_entry("#chess", "ChessAppView", "specific") + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:procdenied", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = json_body(resp).await; + assert!(body["error"].as_str().unwrap().contains("not authorized")); +} + +#[tokio::test] +#[serial] +async fn token_scope_enforcement() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + + seed_procedure_script( + &app, + "function handle(input, params)\nlocal x = xrpc.query('games.birb.chess.getGame', {})\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend", + ).await; + + let entry_id = app + .create_service_entry("#chess", "ChessAppView", "specific") + .await; + app.add_entry_xrpcs(entry_id, &["games.gamesgamesgamesgames.createGame"]) + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:scopecheck", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = json_body(resp).await; + let msg = body["error"].as_str().unwrap(); + assert!( + msg.contains("games.birb.chess.getGame"), + "error should list the missing scope XRPC" + ); +} + +// --------------------------------------------------------------------------- +// Edge cases — identity modes, invalid JWTs, missing fragments +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn not_exposed_rejects_service_auth() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + app.setup_not_exposed().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + let auth = app + .raw_service_auth_jwt( + &plc_store, + "did:plc:notexposed", + "did:plc:fake#chess", + chrono::Utc::now().timestamp() as u64 + 60, + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "not_exposed should reject service auth" + ); +} + +#[tokio::test] +#[serial] +async fn wrong_aud_rejects_service_auth() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let _did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .raw_service_auth_jwt( + &plc_store, + "did:plc:wrongaud", + "did:web:wrong.example.com#chess", + chrono::Utc::now().timestamp() as u64 + 60, + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "wrong aud should reject service auth" + ); +} + +#[tokio::test] +#[serial] +async fn expired_jwt_rejects_service_auth() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .raw_service_auth_jwt( + &plc_store, + "did:plc:expired", + &format!("{}#chess", did), + 1000, + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "expired JWT should reject service auth" + ); +} + +#[tokio::test] +#[serial] +async fn nonexistent_fragment_denies_access() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:nofragment", &did, "#doesNotExist") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = json_body(resp).await; + assert!(body["error"].as_str().unwrap().contains("not authorized")); +} + +#[tokio::test] +#[serial] +async fn did_plc_returns_404_for_did_json() { + common::require_db!(); + let mut app = TestApp::new().await; + let _did = app.setup_did_plc().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/.well-known/did.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +#[serial] +async fn multiple_entries_matched_by_fragment() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + let checkers_id = app + .create_service_entry("#checkers", "CheckersAppView", "specific") + .await; + app.add_entry_xrpcs(checkers_id, &["games.gamesgamesgamesgames.otherGame"]) + .await; + + let auth_chess = app + .service_auth_jwt(&plc_store, "did:plc:multi1", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth_chess) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + let auth_checkers = app + .service_auth_jwt(&plc_store, "did:plc:multi2", &did, "#checkers") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth_checkers) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +#[serial] +async fn scope_check_applies_with_access_mode_all() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script( + &app, + "function handle(input, params)\nlocal x = xrpc.query('games.birb.chess.getGame', {})\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend", + ).await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:scopeall", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = json_body(resp).await; + let msg = body["error"].as_str().unwrap(); + assert!( + msg.contains("games.birb.chess.getGame"), + "scope check should apply even with access_mode=all" + ); +} + +#[tokio::test] +#[serial] +async fn aud_missing_fragment_rejects() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + // aud = instance DID with no fragment + let auth = app + .raw_service_auth_jwt( + &plc_store, + "did:plc:nofrag", + &did, + chrono::Utc::now().timestamp() as u64 + 60, + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "aud without fragment should reject" + ); +} + +// --------------------------------------------------------------------------- +// Auth regression — existing auth paths still work +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn anonymous_access_still_works() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// Static analysis — outbound_xrpcs persistence +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn static_analysis_persistence() { + common::require_db!(); + let app = TestApp::new().await; + + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + app.admin_cookie(), + &json!({ + "lexicon_json": fixtures::game_record_lexicon(), + "backfill": false + }), + )) + .await + .unwrap(); + + app.router + .clone() + .oneshot(admin_post( + "/admin/lexicons", + app.admin_cookie(), + &json!({ + "lexicon_json": fixtures::create_game_procedure_lexicon(), + "target_collection": "games.gamesgamesgamesgames.game" + }), + )) + .await + .unwrap(); + + let resp = app + .router + .clone() + .oneshot(admin_post( + "/admin/scripts", + app.admin_cookie(), + &json!({ + "id": "xrpc.procedure:games.gamesgamesgamesgames.createGame", + "script_type": "lua", + "body": "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend", + "description": "test procedure" + }), + )) + .await + .unwrap(); + + assert!( + resp.status().is_success(), + "POST /admin/scripts returned {}", + resp.status() + ); + let body = json_body(resp).await; + assert!( + body["outbound_xrpcs"].is_null() + || body["outbound_xrpcs"] + .as_array() + .is_some_and(|a| a.is_empty()), + "expected null or empty outbound_xrpcs for script with no XRPC calls" + ); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/admin/scripts/xrpc.procedure%3Agames.gamesgamesgamesgames.createGame") + .header(app.admin_cookie().0, app.admin_cookie().1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "body": "function handle(input, params)\nlocal x = xrpc.query('games.birb.chess.getGame', {})\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_success(), + "PATCH script returned {}", + resp.status() + ); + let body = json_body(resp).await; + let xrpcs = body["outbound_xrpcs"] + .as_array() + .expect("expected outbound_xrpcs array"); + assert_eq!(xrpcs.len(), 1); + assert_eq!(xrpcs[0], "games.birb.chess.getGame"); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/admin/scripts/xrpc.procedure%3Agames.gamesgamesgamesgames.createGame") + .header(app.admin_cookie().0, app.admin_cookie().1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "body": "function handle(input, params)\n-- local x = xrpc.query('games.birb.chess.getGame', {})\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_success(), + "second PATCH returned {}", + resp.status() + ); + let body = json_body(resp).await; + assert!( + body["outbound_xrpcs"].is_null() + || body["outbound_xrpcs"] + .as_array() + .is_some_and(|a| a.is_empty()), + "expected null or empty outbound_xrpcs when only commented-out calls exist" + ); +} + +// --------------------------------------------------------------------------- +// JWT edge cases — forbidden typ, unsupported DID, missing aud +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn forbidden_jwt_typ_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + for forbidden_typ in ["at+jwt", "refresh+jwt", "dpop+jwt"] { + let auth = app + .custom_service_auth_jwt( + &plc_store, + &format!("did:plc:typ{}", forbidden_typ.replace('+', "")), + json!({"alg": "ES256", "typ": forbidden_typ}), + json!({ + "iss": format!("did:plc:typ{}", forbidden_typ.replace('+', "")), + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }), + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "JWT with typ={} should be rejected", + forbidden_typ + ); + } +} + +#[tokio::test] +#[serial] +async fn unsupported_did_method_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + // Use did:key: which is not supported by resolve_signing_key + let auth = app + .custom_service_auth_jwt( + &plc_store, + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + json!({"alg": "ES256"}), + json!({ + "iss": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }), + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "unsupported DID method should be rejected" + ); +} + +#[tokio::test] +#[serial] +async fn jwt_without_aud_field_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let _did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + // JWT payload with no aud field — JwtPayload deserialization fails + let auth = app + .custom_service_auth_jwt( + &plc_store, + "did:plc:noaud", + json!({"alg": "ES256"}), + json!({ + "iss": "did:plc:noaud", + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }), + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "JWT without aud should be rejected" + ); +} + +// --------------------------------------------------------------------------- +// Setup identity — AttachAccount mode stores attached DID +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn set_identity_attach_account_mode() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "mode": "attach_account", + "attached_account_did": "did:plc:testaccount" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Verify status reflects the mode + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = json_body(resp).await; + assert_eq!(body["identity_mode"], "attach_account"); +} + +// --------------------------------------------------------------------------- +// Setup HTTP flow — full endpoint-driven setup produces working identity +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_http_flow_did_web_produces_valid_did_doc() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Step 1: POST /api/setup/identity with mode=did_web + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Step 2: POST /api/setup/complete + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/complete") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Step 3: Rebuild router to pick up identity changes, then verify DID doc + app.rebuild_router(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/.well-known/did.json") + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let doc = json_body(resp).await; + assert_eq!(doc["id"], "did:web:127.0.0.1%3A0"); + assert!(!doc["verificationMethod"].as_array().unwrap().is_empty()); + + // Step 4: Verify status shows complete + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let status = json_body(resp).await; + assert_eq!(status["identity_mode"], "did_web"); + assert_eq!(status["identity_configured"], true); + assert_eq!(status["setup_complete"], true); +} + +// --------------------------------------------------------------------------- +// setup_complete reset — mode change resets setup_complete flag +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn mode_change_resets_setup_complete() { + common::require_db!(); + let mut app = TestApp::new().await; + let _did = app.setup_did_web().await; + let cookie = app.admin_cookie(); + + // Verify setup_complete is true after setup_did_web + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-identity") + .header(cookie.0.clone(), cookie.1.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = json_body(resp).await; + assert_eq!(body["setup_complete"], true); + + // Change mode via PUT — this should reset setup_complete + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-identity") + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "not_exposed"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Verify setup_complete was reset to false + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-identity") + .header(cookie.0.clone(), cookie.1.clone()) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = json_body(resp).await; + assert_eq!(body["mode"], "not_exposed"); + assert_eq!( + body["setup_complete"], false, + "mode change should reset setup_complete" + ); +} + +// --------------------------------------------------------------------------- +// did:web issuer resolution via TLS +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn did_web_issuer_resolved_via_https() { + common::require_db!(); + let mut app = TestApp::new().await; + let instance_did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#appview", "TestAppView", "all") + .await; + + let mut key_bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rng(), &mut key_bytes); + let issuer_key = p256::ecdsa::SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + use p256::elliptic_curve::sec1::ToEncodedPoint; + let public_key = p256::PublicKey::from(issuer_key.verifying_key()); + let compressed = public_key.to_encoded_point(true); + let pub_bytes = compressed.as_bytes().to_vec(); + + let server = + tls::start_did_web_server(move |did| plc::test_did_document(did, &pub_bytes)).await; + let issuer_did = server.issuer_did().to_string(); + + app.use_permissive_http_client(); + + let auth = app.did_web_service_auth_jwt(&issuer_key, &issuer_did, &instance_did, "#appview"); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "did:web issuer should resolve via HTTPS and be allowed with access_mode=all" + ); +} + +// --------------------------------------------------------------------------- +// Service auth with no service entries at all +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn service_auth_rejected_when_no_entries_exist() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:noentries", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = json_body(resp).await; + assert!(body["error"].as_str().unwrap().contains("not authorized")); +} + +// --------------------------------------------------------------------------- +// Service auth with did:plc identity mode +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn service_auth_works_with_did_plc_identity() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_plc().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:plccaller", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "service auth should work when instance uses did:plc identity" + ); +} + +// --------------------------------------------------------------------------- +// Service auth — ES256K (secp256k1) +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn service_auth_es256k_query_allowed() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + // Generate a secp256k1 key pair + use k256::ecdsa::{SigningKey as K256SigningKey, signature::Signer as K256Signer}; + use rand::RngCore; + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let k256_signing_key = K256SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + let k256_verifying_key = k256_signing_key.verifying_key(); + let compressed = k256_verifying_key.to_encoded_point(true); + let pub_bytes = compressed.as_bytes(); + + // Build a DID document with secp256k1 key using EcdsaSecp256k1VerificationKey2019 + // This type uses raw SEC1 key bytes (no multicodec prefix) + let multibase_key = multibase::encode(multibase::Base::Base58Btc, pub_bytes); + + let issuer_did = "did:plc:es256kcaller"; + let did_doc = json!({ + "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], + "id": issuer_did, + "verificationMethod": [{ + "id": format!("{issuer_did}#atproto"), + "type": "EcdsaSecp256k1VerificationKey2019", + "controller": issuer_did, + "publicKeyMultibase": multibase_key + }], + "service": [] + }); + + plc_store + .write() + .await + .insert(issuer_did.to_string(), did_doc); + + // Sign a JWT with ES256K + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + let header = json!({"alg": "ES256K"}); + let payload = json!({ + "iss": issuer_did, + "aud": format!("{did}#chess"), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + + let signature: k256::ecdsa::Signature = k256_signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let auth = format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "ES256K service auth should be accepted" + ); +} + +// --------------------------------------------------------------------------- +// Anonymous POST to procedure is rejected +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn anonymous_procedure_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&serde_json::json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "anonymous POST to procedure should be rejected" + ); +} + +// --------------------------------------------------------------------------- +// Proxy config blocking — XRPC method blocked by proxy policy +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn proxy_config_disabled_rejects_unknown_method() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + // Set proxy config to disabled + app.state + .proxy_config + .store(std::sync::Arc::new(happyview::proxy_config::ProxyConfig { + mode: happyview::proxy_config::ProxyMode::Disabled, + nsids: vec![], + })); + + app.rebuild_router(); + + // Query an unknown method (not in lexicon registry) — should be blocked + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/com.unknown.method") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "disabled proxy config should reject unknown methods" + ); +} + +#[tokio::test] +#[serial] +async fn proxy_config_allowlist_rejects_unlisted_method() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + // Set proxy config to allowlist with a specific pattern + app.state + .proxy_config + .store(std::sync::Arc::new(happyview::proxy_config::ProxyConfig { + mode: happyview::proxy_config::ProxyMode::Allowlist, + nsids: vec!["com.allowed.*".to_string()], + })); + + app.rebuild_router(); + + // Query an unlisted method — should be blocked + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/com.blocked.method") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "allowlist proxy config should reject unlisted methods" + ); +} + +// --------------------------------------------------------------------------- +// Non-scripted procedure via service auth — falls through to OAuth path +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn service_auth_non_scripted_procedure_fails_gracefully() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + // Seed a procedure lexicon but do NOT seed a script for it + seed_procedure_lexicon(&app).await; + + let entry_id = app + .create_service_entry("#chess", "ChessAppView", "specific") + .await; + app.add_entry_xrpcs(entry_id, &["games.gamesgamesgamesgames.createGame"]) + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:noscript", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + // Without a script, service auth goes to the OAuth session path which will fail + // because there's no stored session for the service auth caller. This should + // return a server error, not panic. + assert!( + resp.status().is_client_error() || resp.status().is_server_error(), + "non-scripted procedure via service auth should fail gracefully, got {}", + resp.status() + ); +} + +// --------------------------------------------------------------------------- +// JWT unsupported algorithm rejected +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unsupported_jwt_algorithm_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + // JWT with RS256 algorithm (unsupported) + let auth = app + .custom_service_auth_jwt( + &plc_store, + "did:plc:rs256test", + json!({"alg": "RS256"}), + json!({ + "iss": "did:plc:rs256test", + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }), + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "unsupported JWT algorithm should be rejected" + ); +} + +// --------------------------------------------------------------------------- +// Unauthenticated access to admin service identity endpoints +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unauthenticated_get_service_identity_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-identity") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated GET /admin/service-identity should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_put_service_identity_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "not_exposed"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated PUT /admin/service-identity should be rejected, got {}", + resp.status() + ); +} + +// --------------------------------------------------------------------------- +// Lexicon type mismatch — GET to procedure, POST to query +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_to_procedure_endpoint_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "GET to a procedure endpoint should return 400" + ); +} + +#[tokio::test] +#[serial] +async fn post_to_query_endpoint_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:postquery", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"test": true})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "POST to a query endpoint should return 400" + ); +} + +// --------------------------------------------------------------------------- +// DID doc missing #atproto verification method +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn did_doc_missing_atproto_vm_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use p256::ecdsa::{SigningKey, signature::Signer}; + use rand::RngCore; + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + + let issuer_did = "did:plc:noatprotovm"; + let did_doc = json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": issuer_did, + "verificationMethod": [{ + "id": format!("{issuer_did}#wrongId"), + "type": "Multikey", + "controller": issuer_did, + "publicKeyMultibase": "zNotARealKey" + }], + "service": [] + }); + plc_store + .write() + .await + .insert(issuer_did.to_string(), did_doc); + + let header = json!({"alg": "ES256"}); + let payload = json!({ + "iss": issuer_did, + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + let auth = format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "DID doc without #atproto VM should reject service auth" + ); +} + +#[tokio::test] +#[serial] +async fn did_doc_missing_public_key_multibase_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use p256::ecdsa::{SigningKey, signature::Signer}; + use rand::RngCore; + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + + let issuer_did = "did:plc:nokeymultibase"; + let did_doc = json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": issuer_did, + "verificationMethod": [{ + "id": format!("{issuer_did}#atproto"), + "type": "Multikey", + "controller": issuer_did + }], + "service": [] + }); + plc_store + .write() + .await + .insert(issuer_did.to_string(), did_doc); + + let header = json!({"alg": "ES256"}); + let payload = json!({ + "iss": issuer_did, + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + let auth = format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "DID doc without publicKeyMultibase should reject service auth" + ); +} + +// --------------------------------------------------------------------------- +// JWT allowed typ (e.g., "JWT") is accepted +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn jwt_with_allowed_typ_accepted() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .custom_service_auth_jwt( + &plc_store, + "did:plc:goodtyp", + json!({"alg": "ES256", "typ": "JWT"}), + json!({ + "iss": "did:plc:goodtyp", + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }), + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("host", "127.0.0.1:0") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "JWT with typ=JWT should be accepted" + ); +} diff --git a/tests/e2e_setup.rs b/tests/e2e_setup.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_setup.rs @@ -0,0 +1,800 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; +use common::plc; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +// --------------------------------------------------------------------------- +// Setup status defaults +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_status_returns_defaults_when_no_identity() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["setup_complete"], false); + assert!(body["identity_mode"].is_null()); +} + +// --------------------------------------------------------------------------- +// Setup identity sets mode +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_identity_sets_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["identity_mode"], "did_web"); + assert_eq!(body["identity_configured"], true); +} + +// --------------------------------------------------------------------------- +// Setup identity rejects when complete +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_identity_rejects_when_complete() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "setup identity should reject when setup is complete" + ); +} + +// --------------------------------------------------------------------------- +// Setup complete marks done +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_complete_marks_done() { + common::require_db!(); + let app = TestApp::new().await; + + // Set identity to not_exposed (no encryption key needed) + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "not_exposed"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Mark complete + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/complete") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Verify status + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["setup_complete"], true); +} + +// --------------------------------------------------------------------------- +// Rotation key export +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn rotation_key_export() { + common::require_db!(); + let mut app = TestApp::new().await; + let encryption_key = [0x42u8; 32]; + app.state.config.token_encryption_key = Some(encryption_key); + app.rebuild_router(); + + // Set identity to did_plc via the endpoint (which generates both keys) + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Do NOT mark setup complete -- rotation key export requires setup incomplete + + // GET rotation key + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/rotation-key") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "rotation key export should succeed for did_plc" + ); + + let body_bytes = resp.into_body().collect().await.unwrap().to_bytes(); + assert!( + !body_bytes.is_empty(), + "rotation key response should have binary content" + ); + // The decrypted key should be 32 bytes (P-256 private key) + assert_eq!(body_bytes.len(), 32, "rotation key should be 32 bytes"); +} + +// --------------------------------------------------------------------------- +// Rotation key export rejects non-PLC +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn rotation_key_export_rejects_non_plc() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_web (not complete -- so guard passes but mode check fails) + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // GET rotation key should fail for did_web + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/rotation-key") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "rotation key export should reject non-plc mode" + ); +} + +// --------------------------------------------------------------------------- +// Resolve identity endpoint +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn resolve_identity_empty_query_returns_empty() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/resolve?q=") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let results = body.as_array().unwrap(); + assert!(results.is_empty(), "empty query should return empty array"); +} + +#[tokio::test] +#[serial] +async fn resolve_identity_with_did_returns_result() { + common::require_db!(); + let app = TestApp::new().await; + + // Resolving a DID that doesn't exist should still return the DID as-is (fallback path) + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .uri("/api/setup/resolve?q=did%3Aplc%3Atestresolver") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let results = body.as_array().unwrap(); + assert_eq!(results.len(), 1, "DID input should return one result"); + assert_eq!(results[0]["did"], "did:plc:testresolver"); +} + +// --------------------------------------------------------------------------- +// PLC register endpoint +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn plc_register_creates_did() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_plc via the setup endpoint (generates both keys) + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Register the DID via the PLC directory + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/plc/register") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "plc_register should return 200 with the DID" + ); + let body = json_body(resp).await; + let did = body["did"].as_str().unwrap(); + assert!( + did.starts_with("did:plc:"), + "DID should start with did:plc:" + ); + + // Verify the PLC store received the genesis document + let store = plc_store.read().await; + assert!( + store.contains_key(did), + "PLC store should contain the registered DID" + ); + let genesis = store.get(did).unwrap(); + assert_eq!(genesis["type"], "plc_operation"); + assert!(genesis["sig"].is_string(), "genesis should be signed"); +} + +#[tokio::test] +#[serial] +async fn plc_register_rejects_non_plc_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_web + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/plc/register") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "plc_register should reject non-plc mode" + ); +} + +#[tokio::test] +#[serial] +async fn plc_register_rejects_duplicate() { + common::require_db!(); + let mut app = TestApp::new().await; + let _plc_store = plc::setup_mock_plc(&app.mock_server).await; + + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // First registration succeeds + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/plc/register") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + // Second registration should fail (DID already set) + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/plc/register") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::CONFLICT, + "duplicate plc_register should return 409" + ); +} + +// --------------------------------------------------------------------------- +// Attach auth confirm endpoint +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn attach_auth_confirm_restores_cookie() { + common::require_db!(); + let app = TestApp::new().await; + + // Set up attach_account mode with a known attached DID + let attached_did = "did:plc:attachedaccount"; + happyview::service_identity::upsert_identity( + &app.state.db, + app.state.db_backend, + &happyview::service_identity::IdentityMode::AttachAccount, + None, + None, + None, + Some(attached_did), + ) + .await + .unwrap(); + + // Build a cookie as the attached account (simulating post-OAuth state) + let attached_cookie = + crate::common::auth::admin_cookie_header(attached_did, &app.state.cookie_key); + + // POST attach-auth/confirm to restore the admin's cookie + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/attach-auth/confirm") + .header(attached_cookie.0, attached_cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "original_did": &app.admin_did + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::NO_CONTENT, + "attach_auth_confirm should return 204" + ); + + // Verify the Set-Cookie header is present (cookie was restored) + assert!( + resp.headers().contains_key("set-cookie"), + "response should set a new cookie" + ); +} + +#[tokio::test] +#[serial] +async fn attach_auth_confirm_rejects_invalid_original_did() { + common::require_db!(); + let app = TestApp::new().await; + + let attached_did = "did:plc:attachedaccount2"; + happyview::service_identity::upsert_identity( + &app.state.db, + app.state.db_backend, + &happyview::service_identity::IdentityMode::AttachAccount, + None, + None, + None, + Some(attached_did), + ) + .await + .unwrap(); + + let attached_cookie = + crate::common::auth::admin_cookie_header(attached_did, &app.state.cookie_key); + + // Try with empty original_did + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/attach-auth/confirm") + .header(attached_cookie.0, attached_cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "original_did": "not-a-did" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "invalid original_did should be rejected" + ); +} + +#[tokio::test] +#[serial] +async fn attach_auth_confirm_rejects_mismatched_session() { + common::require_db!(); + let app = TestApp::new().await; + + let attached_did = "did:plc:attachedaccount3"; + happyview::service_identity::upsert_identity( + &app.state.db, + app.state.db_backend, + &happyview::service_identity::IdentityMode::AttachAccount, + None, + None, + None, + Some(attached_did), + ) + .await + .unwrap(); + + // Cookie is for the admin user, but attached_account_did is different + let wrong_cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/attach-auth/confirm") + .header(wrong_cookie.0, wrong_cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "original_did": &app.admin_did + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "mismatched session should be rejected" + ); +} + +// --------------------------------------------------------------------------- +// PLC request/submit — mode rejection +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn plc_request_rejects_non_attach_account_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_web + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/plc/request") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "plc_request should reject non-attach_account mode" + ); +} + +#[tokio::test] +#[serial] +async fn plc_submit_rejects_non_attach_account_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_web + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot( + app.authed_request() + .method("POST") + .uri("/api/setup/plc/submit") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"token": "fake-token"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "plc_submit should reject non-attach_account mode" + ); +} diff --git a/web/.gitignore b/web/.gitignore --- a/web/.gitignore +++ b/web/.gitignore @@ -39,3 +39,9 @@ # typescript *.tsbuildinfo next-env.d.ts + +# Playwright +/test-results/ +/playwright-report/ +/blob-report/ +/playwright/.cache/ diff --git a/web/next.config.ts b/web/next.config.ts --- a/web/next.config.ts +++ b/web/next.config.ts @@ -17,6 +17,7 @@ nextConfig.rewrites = async () => ({ // beforeFiles rewrites run before the trailingSlash redirect, // preventing 308s on API fetch calls. beforeFiles: [ + { source: "/api/:path*", destination: `${apiBase}/api/:path*` }, { source: "/admin/:path*", destination: `${apiBase}/admin/:path*` }, { source: "/auth/:path*", destination: `${apiBase}/auth/:path*` }, { source: "/xrpc/:path*", destination: `${apiBase}/xrpc/:path*` }, @@ -26,6 +27,7 @@ { source: "/config", destination: `${apiBase}/config` }, { source: "/config/", destination: `${apiBase}/config` }, { source: "/oauth/:path*", destination: `${apiBase}/oauth/:path*` }, { source: "/external-auth/:path*", destination: `${apiBase}/external-auth/:path*` }, + { source: "/.well-known/:path*", destination: `${apiBase}/.well-known/:path*` }, ], afterFiles: [], fallback: [], diff --git a/web/package-lock.json b/web/package-lock.json --- a/web/package-lock.json +++ b/web/package-lock.json @@ -37,14 +37,17 @@ "vaul": "^1.1.2", "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.2.0", "@types/node": "^24", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@types/semver": "^7.7.1", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", + "pg": "^8.21.0", "shadcn": "^3.8.5", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", @@ -2152,6 +2155,22 @@ "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", "dev": true, "license": "MIT" + }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } }, "node_modules/@radix-ui/number": { "version": "1.1.1", @@ -4372,6 +4391,18 @@ "dependencies": { "undici-types": "~7.16.0" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -7727,6 +7758,20 @@ "universalify": "^2.0.0" }, "engines": { "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, "node_modules/function-bind": { @@ -11435,6 +11480,103 @@ "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11464,6 +11606,38 @@ "engines": { "node": ">=16.20.0" } }, + "node_modules/playwright": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -11517,6 +11691,49 @@ "engines": { "node": ">=4" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/powershell-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", @@ -12806,6 +13023,16 @@ "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 10.x" } }, "node_modules/stable-hash": { @@ -14149,6 +14376,16 @@ "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" } }, "node_modules/y18n": { diff --git a/web/package.json b/web/package.json --- a/web/package.json +++ b/web/package.json @@ -38,14 +38,17 @@ "vaul": "^1.1.2", "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.2.0", "@types/node": "^24", + "@types/pg": "^8.20.0", "@types/react": "^19", "@types/react-dom": "^19", "@types/semver": "^7.7.1", "babel-plugin-react-compiler": "1.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", + "pg": "^8.21.0", "shadcn": "^3.8.5", "tailwindcss": "^4.2.0", "tw-animate-css": "^1.4.0", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,59 @@ +import { defineConfig } from "@playwright/test" + +export default defineConfig({ + testDir: "./tests/e2e", + fullyParallel: false, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: 1, + reporter: process.env.CI ? [["html"], ["github"]] : [["html"]], + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3200", + trace: "on-first-retry", + }, + projects: [ + { + name: "no-setup", + testMatch: "setup-gate.spec.ts", + use: { browserName: "chromium" }, + }, + { + name: "setup", + testMatch: "setup-wizard.spec.ts", + dependencies: ["no-setup"], + use: { browserName: "chromium" }, + }, + { + name: "post-setup", + testMatch: [ + "service-identity-settings.spec.ts", + "lexicon-services.spec.ts", + "lexicon-delete.spec.ts", + "script-delete.spec.ts", + "record-delete.spec.ts", + "proxy-config.spec.ts", + ], + dependencies: ["setup"], + use: { browserName: "chromium" }, + }, + { + name: "attach-account", + testMatch: "setup-attach-account.spec.ts", + dependencies: ["post-setup"], + use: { browserName: "chromium", ignoreHTTPSErrors: true }, + }, + { + name: "didplc-setup", + testMatch: "setup-didplc.spec.ts", + dependencies: ["attach-account"], + use: { browserName: "chromium" }, + }, + { + name: "setup-features", + testMatch: "setup-features.spec.ts", + dependencies: ["didplc-setup"], + use: { browserName: "chromium" }, + }, + ], + globalSetup: "./tests/e2e/global-setup.ts", +}) diff --git a/web/src/app/dashboard/backfill/page.tsx b/web/src/app/dashboard/backfill/page.tsx --- a/web/src/app/dashboard/backfill/page.tsx +++ b/web/src/app/dashboard/backfill/page.tsx @@ -2,8 +2,10 @@ "use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useVirtualizer } from "@tanstack/react-virtual"; +import { toast } from "sonner"; import { useCurrentUser } from "@/hooks/use-current-user"; +import { toastError } from "@/lib/format"; import { cancelBackfillJob, pauseBackfillJob, @@ -377,13 +379,12 @@ export default function BackfillPage() { const { hasPermission } = useCurrentUser(); const [jobs, setJobs] = useState([]); - const [error, setError] = useState(null); const [selectedJobId, setSelectedJobId] = useState(null); const load = useCallback(() => { getBackfillJobs() .then(setJobs) - .catch((e) => setError(e.message)); + .catch((e) => toastError("Failed to load backfill jobs", e)); }, []); useEffect(() => { @@ -404,8 +405,6 @@ return ( <>
- {error &&

{error}

} -

Backfill Jobs

@@ -425,6 +424,7 @@ Cancel { await flushAllBackfillDetails(); + toast.success("All job details cleared"); setSelectedJobId(null); load(); }}>Clear @@ -456,7 +456,7 @@ - No backfill jobs yet. + No backfill jobs yet. Create a job to import historical records from the AT Protocol network. )} @@ -608,6 +608,7 @@ async function handleCancel() { setCancelling(true); try { await onCancel(); + toast.success("Backfill job cancelled"); } finally { setCancelling(false); } @@ -617,6 +618,7 @@ async function handlePause() { setPausing(true); try { await onPause(); + toast.success("Backfill job paused"); } finally { setPausing(false); } @@ -626,6 +628,7 @@ async function handleResume() { setResuming(true); try { await onResume(); + toast.success("Backfill job resumed"); } finally { setResuming(false); } @@ -923,6 +926,7 @@ Cancel { await flushBackfillDetails(job.id); + toast.success("Job details cleared"); setDiscoveredRepos([]); setDiscoveredCursor(null); setDiscoveredLoaded(false); @@ -1134,7 +1138,6 @@ function CreateDialog({ onSuccess }: { onSuccess: () => void }) { const [collection, setCollection] = useState(null); const [did, setDid] = useState(""); - const [error, setError] = useState(null); const [open, setOpen] = useState(false); const [recordLexicons, setRecordLexicons] = useState([]); @@ -1154,18 +1157,18 @@ } }, [open]); async function handleCreate() { - setError(null); try { await createBackfillJob({ collection: collection || undefined, did: did || undefined, }); + toast.success("Backfill job created"); setCollection(null); setDid(""); setOpen(false); onSuccess(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to create backfill job", e); } } @@ -1194,7 +1197,6 @@ to backfill all collections.
- {error &&

{error}

}
([]); - const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [viewDetail, setViewDetail] = useState(null); const [actionLoading, setActionLoading] = useState(false); const [resolvedFilter, setResolvedFilter] = useState("false"); const [rowSelection, setRowSelection] = useState({}); + const [dismissAllOpen, setDismissAllOpen] = useState(false); const [cursorStack, setCursorStack] = useState([]); const [nextCursor, setNextCursor] = useState(null); @@ -191,7 +204,6 @@ const fetchItems = useCallback( async (cursor?: string) => { setLoading(true); - setError(null); try { const data = await getDeadLetters({ collection: collectionFilter, @@ -203,7 +215,7 @@ setItems(data.dead_letters); setNextCursor(data.cursor); setRowSelection({}); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to load dead letters", e); setItems([]); setNextCursor(null); } finally { @@ -218,6 +230,21 @@ setCursorStack([]); fetchItems(); }, [fetchItems]); + useEffect(() => { + if (Object.keys(rowSelection).length === 0) return; + const validIds = new Set(items.map((item) => item.id)); + const pruned: RowSelectionState = {}; + let changed = false; + for (const [id, selected] of Object.entries(rowSelection)) { + if (validIds.has(id)) { + pruned[id] = selected; + } else { + changed = true; + } + } + if (changed) setRowSelection(pruned); + }, [items]); + function handleNext() { if (!nextCursor) return; setCursorStack((prev) => [...prev, nextCursor]); @@ -237,8 +264,8 @@ async function openDetail(row: DeadLetterSummary) { try { const detail = await getDeadLetter(row.id); setViewDetail(detail); - } catch { - setError("Failed to load detail"); + } catch (e: unknown) { + toastError("Failed to load dead letter detail", e); } } @@ -249,10 +276,11 @@ try { if (action === "retry") await retryDeadLetter(viewDetail.id); else if (action === "reindex") await reindexDeadLetter(viewDetail.id); else await dismissDeadLetter(viewDetail.id); + toast.success(action === "retry" ? "Dead letter retried" : action === "reindex" ? "Dead letter re-indexed" : "Dead letter dismissed"); setViewDetail(null); fetchItems(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError(`Failed to ${action} dead letter`, e); } finally { setActionLoading(false); } @@ -269,6 +297,7 @@ scope: "selected" | "all", ) { setLoading(true); try { + const count = selectedIds.length; const body = scope === "all" ? { all: true, collection: collectionFilter } @@ -276,10 +305,14 @@ : { ids: selectedIds }; if (action === "retry") await bulkRetryDeadLetters(body); else if (action === "reindex") await bulkReindexDeadLetters(body); else await bulkDismissDeadLetters(body); + const verb = action === "retry" ? "retried" : action === "reindex" ? "re-indexed" : "dismissed"; + toast.success(scope === "all" + ? `All matching dead letters ${verb}` + : `${count} dead ${count === 1 ? "letter" : "letters"} ${verb}`); setRowSelection({}); fetchItems(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError(`Failed to ${action} dead letters`, e); } finally { setLoading(false); } @@ -442,8 +475,6 @@ return ( <>
- {error &&

{error}

} -
([]); - const [error, setError] = useState(null); const load = useCallback(() => { getScriptVariables() .then(setVars) - .catch((e) => setError(e.message)); + .catch((e) => toastError("Failed to load variables", e)); }, []); useEffect(() => { @@ -52,9 +64,10 @@ async function handleDeleteVar(key: string) { try { await deleteScriptVariable(key); + toast.success("Variable deleted"); load(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to delete variable", e); } } @@ -62,8 +75,6 @@ return ( <>
- {error &&

{error}

} -

Script Variables

@@ -94,7 +105,7 @@ - No script variables yet. + No script variables yet. Variables defined here are accessible to Lua scripts via the env global table. )} @@ -116,16 +127,37 @@ onSuccess={load} editKey={v.key} /> {hasPermission("script-variables:delete") && ( - + + + + + + + Delete variable? + + This will permanently remove the variable. Scripts + using this variable will fail on next execution. + + + + Cancel + handleDeleteVar(v.key)} + > + Delete + + + + )}
@@ -160,6 +192,7 @@ await upsertScriptVariable({ key: isEdit ? editKey : key, value, }); + toast.success(isEdit ? "Variable updated" : "Variable created"); setKey(editKey ?? ""); setValue(""); setOpen(false); diff --git a/web/src/app/dashboard/settings/labelers/page.tsx b/web/src/app/dashboard/settings/labelers/page.tsx --- a/web/src/app/dashboard/settings/labelers/page.tsx +++ b/web/src/app/dashboard/settings/labelers/page.tsx @@ -2,8 +2,10 @@ "use client"; import { useCallback, useEffect, useState } from "react"; import { Trash2, Pause, Play } from "lucide-react"; +import { toast } from "sonner"; import { useCurrentUser } from "@/hooks/use-current-user"; +import { toastError } from "@/lib/format"; import { getLabelers, addLabeler, @@ -12,6 +14,16 @@ deleteLabeler, } from "@/lib/api"; import type { LabelerSummary } from "@/types/labelers"; import { SiteHeader } from "@/components/site-header"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { @@ -39,14 +51,13 @@ export default function LabelersPage() { const { hasPermission } = useCurrentUser(); const [labelers, setLabelers] = useState([]); const [handles, setHandles] = useState>({}); - const [error, setError] = useState(null); const [deleteDid, setDeleteDid] = useState(null); const [deleting, setDeleting] = useState(false); const load = useCallback(() => { getLabelers() .then(setLabelers) - .catch((e) => setError(e.message)); + .catch((e) => toastError("Failed to load labelers", e)); }, []); useEffect(() => { @@ -77,9 +88,10 @@ async function handleToggleStatus(labeler: LabelerSummary) { try { const newStatus = labeler.status === "active" ? "paused" : "active"; await updateLabeler(labeler.did, { status: newStatus }); + toast.success(labeler.status === "active" ? "Labeler paused" : "Labeler resumed"); load(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to update labeler status", e); } } @@ -88,9 +100,10 @@ setDeleting(true); try { await deleteLabeler(did); setDeleteDid(null); + toast.success("Labeler deleted"); load(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to delete labeler", e); } finally { setDeleting(false); } @@ -100,8 +113,6 @@ return ( <>
- {error &&

{error}

} -

Labeler Subscriptions

@@ -133,7 +144,7 @@ - No labeler subscriptions yet. + No labeler subscriptions yet. Add a labeler to subscribe to external content labeling services. )} @@ -208,43 +219,32 @@
- { - if (!open) setDeleteDid(null); - }} - > - - - Delete labeler? - + { if (!open) setDeleteDid(null); }}> + + + Delete labeler? + This will remove the labeler subscription and delete all labels it has emitted. This action cannot be undone. - - + + {deleteDid && ( {deleteDid} )} - - - - - - - - + + + + ); } @@ -262,10 +262,12 @@ async function handleAdd() { setError(null); try { await addLabeler({ did }); + toast.success("Labeler added"); setDid(""); setOpen(false); onSuccess(); } catch (e: unknown) { + toastError("Failed to add labeler", e); setError(e instanceof Error ? e.message : String(e)); } } diff --git a/web/src/app/dashboard/settings/scripts/page.tsx b/web/src/app/dashboard/settings/scripts/page.tsx --- a/web/src/app/dashboard/settings/scripts/page.tsx +++ b/web/src/app/dashboard/settings/scripts/page.tsx @@ -18,8 +18,10 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ExternalLink, Eye, Trash2 } from "lucide-react"; +import { toast } from "sonner"; import { useCurrentUser } from "@/hooks/use-current-user"; +import { toastError } from "@/lib/format"; import { deleteScript, getScripts } from "@/lib/api"; import type { Script } from "@/types/scripts"; import { @@ -34,6 +36,17 @@ import { DataTable } from "@/components/data-table/data-table"; import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header"; import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"; import { SiteHeader } from "@/components/site-header"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -47,12 +60,11 @@ export default function ScriptsPage() { const { hasPermission } = useCurrentUser(); const router = useRouter(); const [scripts, setScripts] = useState([]); - const [error, setError] = useState(null); const load = useCallback(() => { getScripts() .then(setScripts) - .catch((e) => setError(e instanceof Error ? e.message : String(e))); + .catch((e) => toastError("Failed to load scripts", e)); }, []); useEffect(() => { @@ -74,12 +86,12 @@ [scripts], ); async function handleDelete(id: string) { - if (!confirm(`Delete script '${id}'?`)) return; try { await deleteScript(id); + toast.success("Script deleted"); load(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to delete script", e); } } @@ -224,19 +236,32 @@ {hasPermission("scripts:manage") && ( - + + + + + e.stopPropagation()}> + + Delete script? + + This will permanently remove the script. This action cannot be undone. + + + + Cancel + handleDelete(row.original.id)}>Delete + + + )}
), @@ -288,8 +313,6 @@ return ( <>
- {error &&

{error}

} - diff --git a/web/src/app/dashboard/settings/service-identity/page.tsx b/web/src/app/dashboard/settings/service-identity/page.tsx new file mode 100644 --- /dev/null +++ b/web/src/app/dashboard/settings/service-identity/page.tsx @@ -0,0 +1,1139 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { AlertTriangle, HelpCircle, KeyRound, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { useCurrentUser } from "@/hooks/use-current-user"; +import { toastError } from "@/lib/format"; +import { + getServiceIdentity, + getServiceEntries, + createServiceEntry, + deleteServiceEntry, + updateServiceIdentity, + syncPlc, + syncPlcRequest, + syncPlcSubmit, + confirmAttachAuth, + type ServiceIdentityResponse, + type ServiceEntry, +} from "@/lib/api"; +import { SiteHeader } from "@/components/site-header"; +import { ServiceEntrySheet } from "@/components/service-entry-sheet"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +const SYNC_STORAGE_KEY = "happyview:service-identity:last-synced-at"; +const REAUTH_STORAGE_KEY = "happyview:service-identity:reauth"; +const REAUTH_MAX_AGE_MS = 10 * 60 * 1000; +const IS_MAC = typeof navigator !== "undefined" && /Mac|iPhone/.test(navigator.userAgent); +const MOD_KEY = IS_MAC ? "⌘" : "Ctrl+"; +const FRAGMENT_ID_RE = /^#?[a-zA-Z][a-zA-Z0-9_-]*$/; + +function formatMode(mode: string): string { + switch (mode) { + case "did_web": return "did:web (domain-based)"; + case "did_plc": return "did:plc (PLC directory)"; + case "attach_account": return "Attached account"; + case "not_exposed": return "Not exposed"; + default: return mode; + } +} + +function HelpTip({ label, children }: { label: string; children: React.ReactNode }) { + return ( + + + + + + {children} + + + ); +} + +function getLastSyncedAt(): string | null { + try { + return localStorage.getItem(SYNC_STORAGE_KEY); + } catch { + return null; + } +} + +function setLastSyncedAt() { + try { + localStorage.setItem(SYNC_STORAGE_KEY, new Date().toISOString()); + } catch { + // localStorage unavailable + } +} + +export default function ServiceIdentityPage() { + const router = useRouter(); + const { hasPermission } = useCurrentUser(); + const canManage = hasPermission("settings:manage"); + const [changingMode, setChangingMode] = useState(false); + const [loading, setLoading] = useState(true); + + const [identity, setIdentity] = useState(null); + const [entries, setEntries] = useState([]); + + const [fragmentId, setFragmentId] = useState(""); + const [serviceType, setServiceType] = useState(""); + const [adding, setAdding] = useState(false); + const [addSheetOpen, setAddSheetOpen] = useState(false); + const [filterQuery, setFilterQuery] = useState(""); + + const [selectedEntry, setSelectedEntry] = useState(null); + const [editSheetOpen, setEditSheetOpen] = useState(false); + + // PLC sync state — did_plc uses a popover, attach_account uses a sheet + const [syncPopoverOpen, setSyncPopoverOpen] = useState(false); + const [syncSheetOpen, setSyncSheetOpen] = useState(false); + const [syncing, setSyncing] = useState(false); + const [requestingCode, setRequestingCode] = useState(false); + const [codeRequested, setCodeRequested] = useState(false); + const [plcToken, setPlcToken] = useState(""); + const [submittingToken, setSubmittingToken] = useState(false); + const [sessionDirty, setSessionDirty] = useState(false); + const [selected, setSelected] = useState>(new Set()); + const [bulkDeleting, setBulkDeleting] = useState(false); + const [reauthing, setReauthing] = useState(false); + + const fragmentIdRef = useRef(null); + + const fragmentIdError = fragmentId.trim() && !FRAGMENT_ID_RE.test(fragmentId.trim()) + ? "Must start with a letter and contain only letters, numbers, hyphens, and underscores." + : null; + + const filteredEntries = useMemo(() => { + if (!filterQuery) return entries; + const q = filterQuery.toLowerCase(); + return entries.filter( + (e) => + e.fragment_id.toLowerCase().includes(q) || + e.service_type.toLowerCase().includes(q), + ); + }, [entries, filterQuery]); + + const needsSync = useMemo(() => { + if (sessionDirty) return true; + if (entries.length === 0) return false; + const lastSynced = getLastSyncedAt(); + if (!lastSynced) return true; + return entries.some((e) => e.updated_at > lastSynced); + }, [entries, sessionDirty]); + + const load = useCallback(async () => { + try { + const [id, ents] = await Promise.all([ + getServiceIdentity(), + getServiceEntries(), + ]); + setIdentity(id); + setEntries(ents); + } catch (e: unknown) { + toastError("Failed to load service identity", e); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (localStorage.getItem(REAUTH_STORAGE_KEY)) return; + load(); + }, [load]); + + useEffect(() => { + if (selected.size === 0) return; + const validIds = new Set(entries.map((e) => e.id)); + setSelected((prev) => { + const pruned = new Set([...prev].filter((id) => validIds.has(id))); + return pruned.size === prev.size ? prev : pruned; + }); + }, [entries]); + + useEffect(() => { + if (!canManage) return; + function onKeyDown(e: KeyboardEvent) { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; + const mod = e.metaKey || e.ctrlKey; + if (!mod) return; + + if (e.key === "n") { + e.preventDefault(); + setAddSheetOpen(true); + } + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [canManage]); + + useEffect(() => { + const stored = localStorage.getItem(REAUTH_STORAGE_KEY); + if (!stored) return; + + let payload: { originalDid: string; timestamp?: number }; + try { + payload = JSON.parse(stored); + } catch { + localStorage.removeItem(REAUTH_STORAGE_KEY); + return; + } + + if (payload.timestamp && Date.now() - payload.timestamp > REAUTH_MAX_AGE_MS) { + localStorage.removeItem(REAUTH_STORAGE_KEY); + return; + } + + localStorage.removeItem(REAUTH_STORAGE_KEY); + setReauthing(true); + + fetch("/auth/me", { credentials: "same-origin" }) + .then((res) => { + if (!res.ok) throw new Error("Failed to check session"); + return res.json() as Promise<{ did: string }>; + }) + .then(({ did }) => { + if (did === payload.originalDid) { + // Session is still the admin — user backed out of OAuth + return; + } + return confirmAttachAuth({ original_did: payload.originalDid }).then( + () => { + toast.success("PDS session refreshed"); + load(); + }, + ); + }) + .catch((e) => { + toastError("Failed to restore admin session", e); + }) + .finally(() => setReauthing(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function handleReauthenticate() { + if (!identity?.attached_account_did) return; + const did = identity.attached_account_did; + setReauthing(true); + + Promise.all([ + fetch("/auth/me", { credentials: "same-origin" }).then((res) => { + if (!res.ok) throw new Error("Failed to fetch current user"); + return res.json() as Promise<{ did: string }>; + }), + did.startsWith("did:plc:") + ? fetch(`https://plc.directory/${did}`) + .then((r) => (r.ok ? (r.json() as Promise<{ alsoKnownAs?: string[] }>) : null)) + .then((doc) => { + const aka = doc?.alsoKnownAs?.find((a: string) => a.startsWith("at://")); + return aka ? aka.replace("at://", "") : null; + }) + .catch(() => null) + : Promise.resolve(null), + ]) + .then(([{ did: originalDid }, handle]) => { + localStorage.setItem( + REAUTH_STORAGE_KEY, + JSON.stringify({ originalDid, timestamp: Date.now() }), + ); + const identifier = handle ?? did; + return fetch( + `/auth/login?handle=${encodeURIComponent(identifier)}&scope=${encodeURIComponent("atproto identity:*")}&redirect_uri=${encodeURIComponent("/dashboard/settings/service-identity")}`, + { credentials: "same-origin" }, + ); + }) + .then((resp) => { + if (!resp.ok) throw new Error("Login request failed"); + return resp.json() as Promise<{ url: string }>; + }) + .then(({ url }) => { + window.location.href = url; + }) + .catch((e) => { + toastError("Failed to start re-authentication", e); + setReauthing(false); + }); + } + + const showSyncButton = canManage && identity && + (identity.mode === "did_plc" || identity.mode === "attach_account"); + + async function handleAdd() { + setAdding(true); + try { + const fid = fragmentId.startsWith("#") ? fragmentId : `#${fragmentId}`; + await createServiceEntry({ fragment_id: fid, service_type: serviceType }); + setFragmentId(""); + setServiceType(""); + setAddSheetOpen(false); + setSessionDirty(true); + toast.success("Service entry added", { + description: "Sync to the PLC directory to publish this change.", + }); + await load(); + } catch (e: unknown) { + toastError("Failed to add service entry", e); + } finally { + setAdding(false); + } + } + + async function handleDelete(entry: ServiceEntry) { + try { + await deleteServiceEntry(entry.id); + setSelected((prev) => { + if (!prev.has(entry.id)) return prev; + const next = new Set(prev); + next.delete(entry.id); + return next; + }); + setSessionDirty(true); + toast.success(`Deleted ${entry.fragment_id}`, { + description: "Sync to the PLC directory to publish this change.", + }); + await load(); + } catch (e: unknown) { + toastError("Failed to delete service entry", e); + } + } + + function handleEntryClick(entry: ServiceEntry) { + setSelectedEntry(entry); + setEditSheetOpen(true); + } + + function handleEntrySaved() { + setSessionDirty(true); + load(); + } + + function toggleSelectAll() { + if (selected.size === filteredEntries.length) { + setSelected(new Set()); + } else { + setSelected(new Set(filteredEntries.map((e) => e.id))); + } + } + + function toggleSelect(id: number) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + async function handleBulkDelete() { + setBulkDeleting(true); + const ids = Array.from(selected); + const results = await Promise.allSettled(ids.map((id) => deleteServiceEntry(id))); + const succeeded = ids.filter((_, i) => results[i].status === "fulfilled"); + const failed = ids.length - succeeded.length; + + if (succeeded.length > 0) { + setSelected((prev) => { + const next = new Set(prev); + for (const id of succeeded) next.delete(id); + return next; + }); + setSessionDirty(true); + } + + if (failed === 0) { + toast.success(`Deleted ${succeeded.length} service ${succeeded.length === 1 ? "entry" : "entries"}`, { + description: "Sync to the PLC directory to publish this change.", + }); + } else if (succeeded.length === 0) { + toast.error("Failed to delete service entries"); + } else { + toast.warning(`Deleted ${succeeded.length} of ${ids.length} entries`, { + description: `${failed} ${failed === 1 ? "entry" : "entries"} failed to delete.`, + }); + } + + await load(); + setBulkDeleting(false); + } + + const allSelected = filteredEntries.length > 0 && selected.size === filteredEntries.length; + const someSelected = selected.size > 0 && selected.size < filteredEntries.length; + + async function handleSyncPlc() { + setSyncing(true); + try { + await syncPlc(); + setSessionDirty(false); + setLastSyncedAt(); + setSyncPopoverOpen(false); + toast.success("DID document synced", { + description: "Your service entries are now published to the PLC directory.", + }); + } catch (e: unknown) { + toastError("Failed to sync to PLC directory", e); + } finally { + setSyncing(false); + } + } + + async function handleSyncPlcRequest() { + setRequestingCode(true); + try { + await syncPlcRequest(); + setCodeRequested(true); + toast.success("Confirmation code sent", { + description: "Check the inbox for the attached account's email.", + }); + } catch (e: unknown) { + toastError("Failed to request confirmation code", e); + } finally { + setRequestingCode(false); + } + } + + async function handleSyncPlcSubmit() { + setSubmittingToken(true); + try { + await syncPlcSubmit(plcToken); + setSessionDirty(false); + setLastSyncedAt(); + setSyncSheetOpen(false); + setCodeRequested(false); + setPlcToken(""); + toast.success("DID document synced", { + description: "Your service entries are now published to the PLC directory.", + }); + } catch (e: unknown) { + toastError("Failed to submit confirmation code", e); + } finally { + setSubmittingToken(false); + } + } + + async function handleConfirmChangeMode() { + setChangingMode(true); + try { + await updateServiceIdentity({ mode: "not_exposed" }); + router.push("/setup"); + } catch (e: unknown) { + toastError("Failed to change identity mode", e); + setChangingMode(false); + } + } + + function handleAddKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter" && fragmentId.trim() && serviceType.trim() && !adding && !fragmentIdError) { + handleAdd(); + } + } + + return ( + <> + +
+ + {/* Identity metadata grid */} + {loading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ ) : identity ? ( +
+
+ +
+ {formatMode(identity.mode)} +
+
+
+ +

+ {identity.did ?? (identity.mode === "did_web" ? `did:web:${typeof window !== "undefined" ? window.location.host : "…"}` : not set)} +

+
+
+ +
+ + {identity.setup_complete ? "Complete" : "Incomplete"} + +
+
+
+ ) : ( +
+

No identity configured.

+ +
+ )} + + + + {/* Action bar */} +
+

Service Entries

+
+ {canManage && ( + + + + + + + Change identity mode? + +
+

+ This will reset your service identity configuration + and redirect you to the setup wizard. +

+

+ What changes: +

+
    +
  • DID and signing keys will be regenerated
  • +
  • PLC directory state will need to be re-synced
  • +
+

+ What stays: +

+
    +
  • Service entries are preserved
  • +
  • Records and lexicons are unaffected
  • +
+
+
+
+ + Cancel + + {changingMode ? "Resetting…" : "Continue"} + + +
+
+ )} + {canManage && identity?.mode === "attach_account" && identity.attached_account_did && ( + + )} + {showSyncButton && ( + identity?.mode === "did_plc" ? ( + needsSync ? ( + + + + + +
+

Sync to PLC Directory

+

+ Publish your current service entries. This signs and + submits a PLC update operation. Changes take effect + immediately. +

+
+ + +
+
+
+
+ ) : ( + + + + + + + + Your DID document is up to date. + + + ) + ) : needsSync ? ( + + ) : ( + + + + + + + + Your DID document is up to date. + + + ) + )} + {canManage && ( + + + + + {MOD_KEY}N + + )} +
+
+ + {/* Bulk action bar */} + {selected.size > 0 && ( +
+ + {selected.size} {filterQuery ? `of ${entries.length} ` : ""}{selected.size === 1 ? "entry" : "entries"} selected + + + + + + + + + Delete {selected.size} service {selected.size === 1 ? "entry" : "entries"}? + + + This will remove the selected service entries from your + configuration. Changes take effect in the DID document + after your next PLC sync. + + + + Cancel + + {bulkDeleting ? "Deleting…" : "Delete"} + + + + + +
+ )} + + {/* Filter */} + {!loading && entries.length > 0 && ( +
+ + setFilterQuery(e.target.value)} + placeholder="Filter entries…" + aria-label="Filter service entries" + className="pl-8 h-9" + /> +
+ )} + + {/* Service entries table */} + {loading ? ( +
+ + + + {canManage && } + Fragment ID + Type + XRPC Access + {canManage && } + + + + {[1, 2].map((i) => ( + + {canManage && } + + + + {canManage && } + + ))} + +
+
+ ) : entries.length === 0 ? ( +
+

+ No service entries yet. +

+

+ Service entries define which XRPC endpoints are accessible through + your DID document. Each entry maps a fragment identifier to a + service type. +

+ {canManage && ( + + )} +
+ ) : ( +
+ + + + {canManage && ( + + + + )} + + + Fragment ID + + A unique identifier within the DID document + (e.g. #atproto_pds). + Used by clients to locate this service endpoint. + + + + + + Type + + The AT Protocol service type this entry represents + (e.g. AtprotoPersonalDataServer, BskyAppView). + + + + + + XRPC Access + + Controls which XRPC methods this service can handle. + "All" allows every method; "Specific" restricts + to an allowlist. + + + + {canManage && } + + + + {filteredEntries.length === 0 && filterQuery && ( + + +

+ No entries match “{filterQuery}” +

+ +
+
+ )} + {filteredEntries.map((entry) => ( + + {canManage && ( + + toggleSelect(entry.id)} + aria-label={`Select ${entry.fragment_id}`} + /> + + )} + + + + {entry.service_type} + + + {entry.access_mode === "all" ? "All XRPCs" : "Specific"} + + + {canManage && ( + + + + + + + + + Delete {entry.fragment_id}? + + + This will remove the service entry from your + configuration. The change will take effect in the + DID document after your next PLC sync. + + + + Cancel + handleDelete(entry)} + > + Delete + + + + + + )} + + ))} +
+
+
+ )} +
+ + {/* Edit service entry sheet */} + {selectedEntry && ( + + )} + + {/* Add service entry sheet */} + { + setAddSheetOpen(open); + if (!open) { + setFragmentId(""); + setServiceType(""); + } else { + requestAnimationFrame(() => fragmentIdRef.current?.focus()); + } + }}> + + + Add Service Entry + + Register a new service endpoint in your DID document. + + + +
+
+ + setFragmentId(e.target.value)} + onKeyDown={handleAddKeyDown} + placeholder="#atproto_pds" + /> + {fragmentIdError ? ( +

{fragmentIdError}

+ ) : ( +

+ A # will be prepended automatically if omitted. +

+ )} +
+
+ + setServiceType(e.target.value)} + onKeyDown={handleAddKeyDown} + placeholder="AtprotoPersonalDataServer" + /> +
+
+ + + + +
+
+ + {/* Sync PLC sheet — attach_account mode only (did_plc uses popover) */} + { + setSyncSheetOpen(open); + if (!open) { + setCodeRequested(false); + setPlcToken(""); + } + }}> + + + Sync to PLC Directory + + Publish your current service entries to the{" "} + + PLC directory + + . This updates your public DID document so clients can discover + your service endpoints. + + + +
+ {!codeRequested && ( +
+

+ Because this identity is attached to an existing account, + syncing requires a confirmation code sent to the + account's email address. +

+ +
+ )} + + {codeRequested && ( +
+

+ Check the inbox for the attached account's email. + The code expires after a few minutes. +

+
+ + setPlcToken(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && plcToken.trim() && !submittingToken) { + handleSyncPlcSubmit(); + } + }} + placeholder="Enter the code from your email" + /> +
+
+ + + +
+
+ )} +
+
+
+ + ); +} diff --git a/web/src/app/dashboard/settings/users/page.tsx b/web/src/app/dashboard/settings/users/page.tsx --- a/web/src/app/dashboard/settings/users/page.tsx +++ b/web/src/app/dashboard/settings/users/page.tsx @@ -5,6 +5,7 @@ import { ChevronRight, Search, Shield, Trash2 } from "lucide-react"; import { toast } from "sonner"; import { useAuth } from "@/lib/auth-context"; +import { toastError } from "@/lib/format"; import { getUsers, addUser, @@ -15,6 +16,17 @@ getPermissions, } from "@/lib/api"; import type { PermissionEntry, PermissionTemplate } from "@/lib/api"; import type { UserSummary } from "@/types/users"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; import { SiteHeader } from "@/components/site-header"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -77,7 +89,6 @@ export default function UsersPage() { const { did: currentDid } = useAuth(); const [users, setUsers] = useState([]); const [handles, setHandles] = useState>({}); - const [error, setError] = useState(null); const [selectedUserId, setSelectedUserId] = useState(null); const [pendingPermissions, setPendingPermissions] = useState([]); const [saving, setSaving] = useState(false); @@ -124,7 +135,7 @@ const load = useCallback(() => { getUsers() .then(setUsers) - .catch((e) => setError(e instanceof Error ? e.message : String(e))); + .catch((e) => toastError("Failed to load users", e)); }, []); useEffect(() => { @@ -134,7 +145,7 @@ .then((catalog) => { setPermissionEntries(catalog.permissions); setTemplates(catalog.templates); }) - .catch((e) => setError(e instanceof Error ? e.message : String(e))); + .catch((e) => toastError("Failed to load permissions", e)); }, [load]); // Resolve DIDs to handles via PLC directory @@ -190,9 +201,10 @@ async function handleDelete(id: string) { try { await deleteUser(id); + toast.success("User deleted"); load(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to delete user", e); } } @@ -253,7 +265,7 @@ await updateUserPermissions(userId, body); toast.success("Permissions updated"); load(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to save permissions", e); } finally { setSaving(false); } @@ -262,9 +274,10 @@ async function handleTransferSuper(targetUserId: string) { try { await transferSuper({ target_user_id: targetUserId }); + toast.success("Ownership transferred"); load(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to transfer ownership", e); } } @@ -272,8 +285,6 @@ return ( <>
- {error &&

{error}

} -

Users

{(isCurrentUserSuper || @@ -304,7 +315,7 @@ - No users yet. + No users yet. The first authenticated user is automatically granted owner permissions. )} @@ -550,25 +561,37 @@
- + + + + + + + Delete user? + + This will permanently remove the user and revoke all their permissions. This action cannot be undone. + + + + Cancel + { handleDelete(selectedUser.id); setSelectedUserId(null); }}>Delete + + + {isCurrentUserSuper && ( ; }) { const [did, setDid] = useState(""); const [template, setTemplate] = useState(""); - const [error, setError] = useState(null); const [open, setOpen] = useState(false); async function handleAdd() { - setError(null); try { const body: { did: string; template?: string } = { did }; if (template) body.template = template; await addUser(body); + toast.success("User added"); setDid(""); setTemplate(""); setOpen(false); onSuccess(); } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)); + toastError("Failed to add user", e); } } @@ -800,7 +822,6 @@ template.
- {error &&

{error}

}
{ + if (!did) { + router.replace("/login") + return + } + + getSetupStatus() + .then((status) => { + if (status.setup_complete) { + router.replace("/dashboard") + } else { + setReady(true) + } + }) + .catch(() => { + setBackendError(true) + setReady(true) + }) + }, [did, router]) + + return ( +
+
+
+

Welcome to HappyView

+

Let's get your AppView ready for the AT Protocol network.

+
+ {backendError && ( +
+ Could not reach the backend. Setup steps may not save correctly. +
+ )} + {ready ? : ( +
+ + +
+ )} +
+
+ ) +} diff --git a/web/src/components/app-sidebar.tsx b/web/src/components/app-sidebar.tsx --- a/web/src/components/app-sidebar.tsx +++ b/web/src/components/app-sidebar.tsx @@ -21,6 +21,7 @@ IconArrowsShuffle, IconCode, IconSkull, IconFlask, + IconFingerprint, } from "@tabler/icons-react"; import Image from "next/image"; import Link from "next/link"; @@ -113,6 +114,12 @@ { title: "General", url: "/dashboard/settings/general", icon: IconSettings, + requiredPermissions: ["settings:manage"], + }, + { + title: "Service Identity", + url: "/dashboard/settings/service-identity", + icon: IconFingerprint, requiredPermissions: ["settings:manage"], }, { diff --git a/web/src/components/lexicon-services-sheet.tsx b/web/src/components/lexicon-services-sheet.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/lexicon-services-sheet.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { getLexiconServices, removeServiceEntryXrpcs } from "@/lib/api"; +import type { ServiceEntry } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; + +interface LexiconServicesSheetProps { + lexiconId: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function LexiconServicesSheet({ + lexiconId, + open, + onOpenChange, +}: LexiconServicesSheetProps) { + const [services, setServices] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [removing, setRemoving] = useState(null); + + const load = useCallback(() => { + if (!open) return; + setLoading(true); + setError(null); + getLexiconServices(lexiconId) + .then(setServices) + .catch((e) => setError(e instanceof Error ? e.message : String(e))) + .finally(() => setLoading(false)); + }, [lexiconId, open]); + + useEffect(() => { + load(); + }, [load]); + + async function handleRemove(service: ServiceEntry) { + setRemoving(service.id); + setError(null); + try { + await removeServiceEntryXrpcs(service.id, [lexiconId]); + load(); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setRemoving(null); + } + } + + return ( + + + + Services + + Services that can access{" "} + {lexiconId}. + + + +
+ {error && ( +

{error}

+ )} + + {loading ? ( +

Loading...

+ ) : services.length === 0 ? ( +

+ No services have access to this XRPC. +

+ ) : ( + + + + Service + Access + + + + + {services.map((service) => { + const isAllXrpcs = service.access_mode === "all"; + return ( + + + {service.fragment_id} + + + {isAllXrpcs ? ( + + All XRPCs + + ) : ( + + Explicit + + )} + + + {isAllXrpcs ? ( + + + + + — + + + + Change access mode in service config to remove + individual XRPCs. + + + + ) : ( + + )} + + + ); + })} + +
+ )} +
+ +
+

+ Services with “All XRPCs” access can’t be removed + from individual XRPCs. Change their access mode in service config. +

+
+
+
+ ); +} diff --git a/web/src/components/service-entry-sheet.tsx b/web/src/components/service-entry-sheet.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/service-entry-sheet.tsx @@ -0,0 +1,322 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { Trash2 } from "lucide-react"; +import { toast } from "sonner"; + +import { toastError } from "@/lib/format"; +import { + getServiceEntryXrpcs, + updateServiceEntry, + removeServiceEntryXrpcs, + addServiceEntryXrpcs, + deleteServiceEntry, + type ServiceEntry, +} from "@/lib/api"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface ServiceEntrySheetProps { + entry: ServiceEntry; + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: () => void; +} + +export function ServiceEntrySheet({ + entry, + open, + onOpenChange, + onSaved, +}: ServiceEntrySheetProps) { + const [accessMode, setAccessMode] = useState(entry.access_mode); + const [xrpcs, setXrpcs] = useState([]); + const [selected, setSelected] = useState>(new Set()); + const [newXrpc, setNewXrpc] = useState(""); + const [adding, setAdding] = useState(false); + const [saving, setSaving] = useState(false); + const [deleting, setDeleting] = useState(false); + + const loadXrpcs = useCallback(async () => { + if (accessMode !== "specific") return; + try { + const list = await getServiceEntryXrpcs(entry.id); + setXrpcs(list); + } catch (e: unknown) { + toastError("Failed to load XRPC list", e); + } + }, [entry.id, accessMode]); + + useEffect(() => { + if (open) { + setAccessMode(entry.access_mode); + setSelected(new Set()); + } + }, [open, entry]); + + useEffect(() => { + if (open) { + loadXrpcs(); + } + }, [open, loadXrpcs]); + + function toggleSelectAll() { + if (selected.size === xrpcs.length) { + setSelected(new Set()); + } else { + setSelected(new Set(xrpcs)); + } + } + + function toggleSelect(xrpc: string) { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(xrpc)) { + next.delete(xrpc); + } else { + next.add(xrpc); + } + return next; + }); + } + + async function handleRemoveSelected() { + if (selected.size === 0) return; + try { + await removeServiceEntryXrpcs(entry.id, Array.from(selected)); + toast.success(`Removed ${selected.size} XRPC${selected.size > 1 ? "s" : ""}`); + setSelected(new Set()); + await loadXrpcs(); + } catch (e: unknown) { + toastError("Failed to remove XRPCs", e); + } + } + + async function handleAddXrpc() { + const value = newXrpc.trim(); + if (!value) return; + setAdding(true); + try { + await addServiceEntryXrpcs(entry.id, [value]); + toast.success(`Added ${value}`); + setNewXrpc(""); + await loadXrpcs(); + } catch (e: unknown) { + toastError("Failed to add XRPC", e); + } finally { + setAdding(false); + } + } + + async function handleSave() { + setSaving(true); + try { + await updateServiceEntry(entry.id, { access_mode: accessMode }); + toast.success("Service entry updated"); + onSaved(); + onOpenChange(false); + } catch (e: unknown) { + toastError("Failed to update service entry", e); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + setDeleting(true); + try { + await deleteServiceEntry(entry.id); + toast.success(`Deleted ${entry.fragment_id}`); + onSaved(); + onOpenChange(false); + } catch (e: unknown) { + toastError("Failed to delete service entry", e); + } finally { + setDeleting(false); + } + } + + const allSelected = xrpcs.length > 0 && selected.size === xrpcs.length; + const someSelected = selected.size > 0 && selected.size < xrpcs.length; + + return ( + + + + {entry.fragment_id} + {entry.service_type} + + +
+
+

XRPC Access

+
+ + +
+
+ + {accessMode === "specific" && ( +
+
+

Allowed XRPCs

+ {selected.size > 0 && ( + + )} +
+ +
+ + + + + + + XRPC + + + + {xrpcs.length === 0 && ( + + + No XRPCs configured. Add methods below that this + service entry can access. + + + )} + {xrpcs.map((xrpc) => ( + + + toggleSelect(xrpc)} + aria-label={`Select ${xrpc}`} + /> + + + {xrpc} + + + ))} + +
+
+ +
+ setNewXrpc(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleAddXrpc(); + }} + disabled={adding} + /> + +
+
+ )} +
+ + + + + + + + + + Delete {entry.fragment_id}? + + + This will permanently remove the service entry and its XRPC + configuration. The change will take effect in the DID document + after your next PLC sync. + + + + Cancel + + {deleting ? "Deleting…" : "Delete"} + + + + + + +
+
+ ); +} diff --git a/web/src/components/setup/help-tip.tsx b/web/src/components/setup/help-tip.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/setup/help-tip.tsx @@ -0,0 +1,38 @@ +"use client" + +import { HelpCircle } from "lucide-react" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" + +interface HelpTipProps { + label: string + href?: string +} + +export function HelpTip({ label, href }: HelpTipProps) { + return ( + + + + {href ? ( + + + + ) : ( + + + + )} + + + {label} + + + + ) +} diff --git a/web/src/components/setup/setup-attach-auth.tsx b/web/src/components/setup/setup-attach-auth.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/setup/setup-attach-auth.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { confirmAttachAuth } from "@/lib/api"; +import { docsUrl } from "@/lib/docs"; + +const ATTACH_AUTH_STORAGE_KEY = "happyview_attach_auth"; +const ATTACH_AUTH_MAX_AGE_MS = 10 * 60 * 1000; + +interface AttachAuthPayload { + attachedDid: string; + originalDid: string; + timestamp?: number; +} + +interface SetupAttachAuthProps { + attachedDid: string; + attachedHandle: string | null; + onComplete: () => void; + onBack?: () => void; +} + +export function SetupAttachAuth({ + attachedDid, + attachedHandle, + onComplete, + onBack, +}: SetupAttachAuthProps) { + const [confirming, setConfirming] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const stored = localStorage.getItem(ATTACH_AUTH_STORAGE_KEY); + if (!stored) return; + + let payload: AttachAuthPayload; + try { + payload = JSON.parse(stored) as AttachAuthPayload; + } catch { + localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY); + return; + } + + if (payload.attachedDid !== attachedDid) { + localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY); + return; + } + + if ( + payload.timestamp && + Date.now() - payload.timestamp > ATTACH_AUTH_MAX_AGE_MS + ) { + localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY); + setError("Your sign-in session expired. Please authenticate again."); + return; + } + + localStorage.removeItem(ATTACH_AUTH_STORAGE_KEY); + setConfirming(true); + + confirmAttachAuth({ original_did: payload.originalDid }) + .then(() => onComplete()) + .catch((e) => { + setError( + e instanceof Error + ? e.message + : "Failed to restore admin session. Try authenticating again.", + ); + setConfirming(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + function handleAuthenticate() { + setConfirming(true); + setError(null); + + fetch("/auth/me", { credentials: "same-origin" }) + .then((res) => { + if (!res.ok) throw new Error("Failed to fetch current user"); + return res.json() as Promise<{ did: string }>; + }) + .then(({ did: originalDid }) => { + const payload: AttachAuthPayload = { + attachedDid, + originalDid, + timestamp: Date.now(), + }; + localStorage.setItem(ATTACH_AUTH_STORAGE_KEY, JSON.stringify(payload)); + + const handle = attachedHandle ?? attachedDid; + return fetch(`/auth/login?handle=${encodeURIComponent(handle)}&scope=${encodeURIComponent("atproto identity:*")}&redirect_uri=${encodeURIComponent("/setup")}`, { + credentials: "same-origin", + }); + }) + .then((resp) => { + if (!resp.ok) throw new Error("Login request failed"); + return resp.json() as Promise<{ url: string }>; + }) + .then(({ url }) => { + window.location.href = url; + }) + .catch((e) => { + setError( + e instanceof Error + ? e.message + : "Failed to start authentication. Check your connection and try again.", + ); + setConfirming(false); + }); + } + + const displayName = attachedHandle ? `@${attachedHandle}` : attachedDid; + + return ( + + + Sign in to verify ownership + {/* + You'll be redirected to sign in as{" "} + {displayName}, then returned here + automatically.
+
*/} +
+ +

+ You'll leave this page briefly to authenticate through the + account's data server. Once verified, your admin session will be + restored and you'll continue from where you left off. +
+ + Learn more + +

+ {error && ( +

+ {error} +

+ )} + {confirming ? ( +

+ Restoring admin session… +

+ ) : ( +
+ {onBack ? ( + + ) : ( +
+ )} + +
+ )} + + + ); +} diff --git a/web/src/components/setup/setup-complete.tsx b/web/src/components/setup/setup-complete.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/setup/setup-complete.tsx @@ -0,0 +1,97 @@ +"use client" + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { CheckCircle2, FileUp, Settings, LayoutDashboard } from "lucide-react" +import { useRouter } from "next/navigation" +import { completeSetup } from "@/lib/api" +import { docsUrl } from "@/lib/docs" +import { useEffect, useState } from "react" + +interface SetupCompleteProps { identityMode: string | null } + +const MODE_LABELS: Record = { + did_web: "Domain identity (did:web)", + did_plc: "Network identity (did:plc)", + attach_account: "Linked AT Protocol account", + not_exposed: "Skipped — using built-in auth", +} + +export function SetupComplete({ identityMode }: SetupCompleteProps) { + const router = useRouter() + const [error, setError] = useState(null) + + useEffect(() => { + if (identityMode === "not_exposed") { + completeSetup().catch((e) => { + setError(e instanceof Error ? e.message : "Failed to finalize setup. You can retry from the dashboard settings.") + }) + } + }, [identityMode]) + + const modeLabel = identityMode ? MODE_LABELS[identityMode] ?? identityMode : "Configured" + + return ( + + +
+
+ +
+
+ Your AppView is ready + + {identityMode === "not_exposed" + ? "HappyView is running with built-in auth. You can configure a service identity anytime from settings." + : "Your service identity is configured and your AppView is ready to accept requests from the AT Protocol network."} + +
+ +
+
Service identity
+
{modeLabel}
+
+ What does this mean? +
+
+ + {error &&

{error}

} + +
+

Next steps

+
+ + +
+
+ +
+ +
+
+
+ ) +} diff --git a/web/src/components/setup/setup-configure.tsx b/web/src/components/setup/setup-configure.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/setup/setup-configure.tsx @@ -0,0 +1,260 @@ +"use client" + +import { useCallback, useEffect, useId, useRef, useState } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { setSetupIdentity, resolveIdentity, type ResolveResult } from "@/lib/api" + +interface SetupConfigureProps { + mode: string + onComplete: (opts?: { attachedDid?: string; attachedHandle?: string | null }) => void + onBack?: () => void +} + +export function SetupConfigure({ mode, onComplete, onBack }: SetupConfigureProps) { + if (mode === "attach_account") { + return onComplete(opts)} onBack={onBack} /> + } + + return null +} + +function AttachAccountForm({ onComplete, onBack }: { + onComplete: (opts: { attachedDid: string; attachedHandle: string | null }) => void + onBack?: () => void +}) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [inputValue, setInputValue] = useState("") + const [suggestions, setSuggestions] = useState([]) + const [showSuggestions, setShowSuggestions] = useState(false) + const [selectedProfile, setSelectedProfile] = useState(null) + const [resolving, setResolving] = useState(false) + const [focusedIndex, setFocusedIndex] = useState(-1) + const debounceRef = useRef | null>(null) + const containerRef = useRef(null) + const listboxId = useId() + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setShowSuggestions(false) + } + } + document.addEventListener("mousedown", handleClickOutside) + return () => document.removeEventListener("mousedown", handleClickOutside) + }, []) + + const searchIdentity = useCallback(async (query: string) => { + const q = query.trim() + if (q.length < 2) { + setSuggestions([]) + setShowSuggestions(false) + return + } + + setResolving(true) + try { + const results = await resolveIdentity(q) + setSuggestions(results) + setShowSuggestions(true) + setFocusedIndex(-1) + } catch { + setSuggestions([]) + setShowSuggestions(false) + setFocusedIndex(-1) + } finally { + setResolving(false) + } + }, []) + + function handleInputChange(value: string) { + setInputValue(value) + setSelectedProfile(null) + + if (debounceRef.current) clearTimeout(debounceRef.current) + debounceRef.current = setTimeout(() => searchIdentity(value), 300) + } + + function selectResult(result: ResolveResult) { + setSelectedProfile(result) + setInputValue(result.handle ?? result.did) + setShowSuggestions(false) + setSuggestions([]) + } + + function clearSelection() { + setSelectedProfile(null) + setInputValue("") + setSuggestions([]) + } + + async function handleSubmit() { + const did = selectedProfile?.did ?? inputValue.trim() + if (!did) return + + setLoading(true) + setError(null) + try { + await setSetupIdentity({ mode: "attach_account", attached_account_did: did }) + onComplete({ + attachedDid: did, + attachedHandle: selectedProfile?.handle ?? null, + }) + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to link account. Check the identifier and try again.") + } finally { + setLoading(false) + } + } + + const trimmedInput = inputValue.trim() + const looksValid = selectedProfile != null || /^did:[a-z]+:.+/.test(trimmedInput) || trimmedInput.includes(".") + const showFormatHint = trimmedInput.length >= 2 && !looksValid && !resolving + + const displayName = selectedProfile?.display_name ?? selectedProfile?.handle ?? selectedProfile?.did + const avatarFallback = displayName?.charAt(0).toUpperCase() ?? "?" + const hasSuggestions = showSuggestions && suggestions.length > 0 + const showEmpty = showSuggestions && suggestions.length === 0 && !resolving && trimmedInput.length >= 2 + + return ( + + + Find your account + Search for the AT Protocol account you want to link to this AppView. + + +
+ +
+ handleInputChange(e.target.value)} + onFocus={() => { if (suggestions.length > 0) setShowSuggestions(true) }} + onKeyDown={(e) => { + if (e.key === "Enter") { + if (hasSuggestions && focusedIndex >= 0) { + e.preventDefault() + selectResult(suggestions[focusedIndex]) + } else if (!hasSuggestions && looksValid && trimmedInput && !loading) { + e.preventDefault() + handleSubmit() + } + return + } + if (!hasSuggestions) return + if (e.key === "ArrowDown") { + e.preventDefault() + setFocusedIndex((i) => (i + 1) % suggestions.length) + } else if (e.key === "ArrowUp") { + e.preventDefault() + setFocusedIndex((i) => (i <= 0 ? suggestions.length - 1 : i - 1)) + } else if (e.key === "Escape") { + setShowSuggestions(false) + setFocusedIndex(-1) + } + }} + autoComplete="off" + disabled={loading} + aria-required="true" + role="combobox" + aria-expanded={hasSuggestions} + aria-controls={listboxId} + aria-autocomplete="list" + aria-activedescendant={focusedIndex >= 0 ? `${listboxId}-option-${focusedIndex}` : undefined} + /> + {resolving && ( + + Resolving… + + )} + {hasSuggestions && ( +
+ {suggestions.map((result, index) => { + const name = result.display_name ?? result.handle ?? result.did + const fallback = name.charAt(0).toUpperCase() + return ( + + ) + })} +
+ )} + {showEmpty && ( +
+

No accounts found. Try a full handle (e.g. alice.bsky.social) or a DID.

+
+ )} +
+ {showFormatHint && ( +

Enter a handle (e.g. alice.bsky.social) or a DID (e.g. did:plc:...).

+ )} +
+ + {selectedProfile && ( +
+ + {selectedProfile.avatar && } + {avatarFallback} + +
+ {selectedProfile.display_name && ( +

{selectedProfile.display_name}

+ )} +

+ {selectedProfile.handle ? `@${selectedProfile.handle}` : selectedProfile.did} +

+

{selectedProfile.did}

+
+ +
+ )} + + {error &&

{error}

} +
+ {onBack ? ( + + ) :
} + +
+ + + ) +} diff --git a/web/src/components/setup/setup-identity-mode.tsx b/web/src/components/setup/setup-identity-mode.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/setup/setup-identity-mode.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { docsUrl } from "@/lib/docs"; +import { HelpTip } from "./help-tip"; + +const IDENTITY_MODES = [ + { + value: "did_web", + title: "Use your domain", + description: + "Your domain name becomes your identity. This is the simplest option, since HappyView will generate everything automatically.", + helpTip: + "Uses your domain as a did:web identifier. Your server hosts a DID document at /.well-known/did.json. The identity is tied to your domain.", + badge: "Recommended", + }, + { + value: "attach_account", + title: "Use an existing AT Protocol account", + description: + "Link this AppView to an account you already own. You'll verify ownership through that account.", + helpTip: + "Links your AppView to an existing account's DID. Authentication goes through that account's Personal Data Server.", + badge: null, + }, + { + value: "did_plc", + title: "Create a new network identity", + description: ( + <> + Register a new identity in the AT Protocol directory. This is the most + durable option because a did:plc will survive domain + changes. + + ), + helpTip: + "Registers a did:plc identity in the AT Protocol directory. Supports key rotation and recovery, and isn't tied to any single domain.", + badge: null, + }, +]; + +interface SetupIdentityModeProps { + onComplete: (mode: string) => void | Promise; +} + +export function SetupIdentityMode({ onComplete }: SetupIdentityModeProps) { + const [selected, setSelected] = useState(null); + const [submitting, setSubmitting] = useState(false); + + return ( + + + Set up your service identity + + AT Protocol apps typically verify requests through a user's data + server before they reach your AppView. To accept those requests, your + AppView needs its own identity on the network. +
+
+ This is optional. HappyView includes its own auth, + but a service identity is recommended for compatibility with standard + AT Protocol apps. +
+ + Learn more + +
+
+ +
+ {IDENTITY_MODES.map((mode) => ( + + ))} + +
+ +
+
+ +
+ +
+
+
+ ); +} diff --git a/web/src/components/setup/setup-verify.tsx b/web/src/components/setup/setup-verify.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/setup/setup-verify.tsx @@ -0,0 +1,317 @@ +"use client" + +import { useEffect, useState } from "react" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { completeSetup, plcRequest, plcSubmit, plcRegister } from "@/lib/api" +import { docsUrl } from "@/lib/docs" +import { HelpTip } from "./help-tip" + +interface SetupVerifyProps { + mode: string + onComplete: () => void + onBack?: () => void +} + +// ─── did:web ──────────────────────────────────────────────────────────────── + +function VerifyDidWeb({ onComplete, onBack }: { onComplete: () => void; onBack?: () => void }) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [didId, setDidId] = useState(null) + const [fetching, setFetching] = useState(true) + const [fetchError, setFetchError] = useState(false) + + useEffect(() => { + fetch("/.well-known/did.json") + .then((res) => { + if (!res.ok) throw new Error() + return res.json() + }) + .then((doc) => { if (doc?.id) setDidId(doc.id) }) + .catch(() => setFetchError(true)) + .finally(() => setFetching(false)) + }, []) + + const handleConfirm = async () => { + setLoading(true) + setError(null) + try { await completeSetup(); onComplete() } + catch (e) { setError(e instanceof Error ? e.message : "Failed to complete setup. Check your backend connection and try again.") } + finally { setLoading(false) } + } + + return ( + + + Review your domain identity + A signing key has been generated and your identity document is ready. Learn more + + + {fetching ? ( +

Checking identity document…

+ ) : ( +
+ {fetchError && ( +
+ Could not load your DID document from /.well-known/did.json. Your identity may not be configured correctly. +
+ )} + {didId && ( +
+
Identity
+
{didId}
+
+ )} +
+
Document URL
+
/.well-known/did.json
+
+
+
Signing key
+
P-256 keypair, encrypted at rest
+
+
+ )} +

You can add service entries after setup from the Service Identity settings page.

+ {error &&

{error}

} +
+ {onBack ? ( + + ) :
} + +
+ + + ) +} + +// ─── attach_account ────────────────────────────────────────────────────────── + +function VerifyAttachAccount({ onComplete, onBack }: { onComplete: () => void; onBack?: () => void }) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [token, setToken] = useState("") + const [codeSent, setCodeSent] = useState(false) + const [sendingCode, setSendingCode] = useState(false) + + useEffect(() => { + let cancelled = false + setSendingCode(true) + setError(null) + plcRequest() + .then(() => { if (!cancelled) setCodeSent(true) }) + .catch((e) => { if (!cancelled) setError(e instanceof Error ? e.message : "Failed to send confirmation code. Check your connection and try again.") }) + .finally(() => { if (!cancelled) setSendingCode(false) }) + return () => { cancelled = true } + }, []) + + const handleSendCode = async () => { + setSendingCode(true) + setError(null) + try { + await plcRequest() + setCodeSent(true) + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to send confirmation code. Check your connection and try again.") + } finally { + setSendingCode(false) + } + } + + const handleSubmitToken = async () => { + setLoading(true) + setError(null) + try { + await plcSubmit(token) + await completeSetup() + onComplete() + } catch (e) { + setError(e instanceof Error ? e.message : "Verification failed. Check the code and try again.") + } finally { + setLoading(false) + } + } + + return ( + + + Enter your confirmation code + + {codeSent + ? "A code has been sent to the email address on this account." + : "We'll send a confirmation code to the email address on this account."}{" "} + Learn more + + + + {!codeSent ? ( + + ) : ( +
{ e.preventDefault(); if (token && !loading) handleSubmitToken() }} className="space-y-4"> +
+ + setToken(e.target.value)} className="mt-1.5" aria-required="true" /> +
+ + {error &&

{error}

} +
+ {onBack ? ( + + ) :
} + +
+ + )} + {!codeSent && error &&

{error}

} + + + ) +} + +// ─── did:plc ───────────────────────────────────────────────────────────────── + +function VerifyDidPlc({ onComplete, onBack }: { onComplete: () => void; onBack?: () => void }) { + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [registering, setRegistering] = useState(true) + const [registeredDid, setRegisteredDid] = useState(null) + const [regError, setRegError] = useState(null) + const [keyDownloaded, setKeyDownloaded] = useState(false) + const [downloading, setDownloading] = useState(false) + const [downloadError, setDownloadError] = useState(null) + + useEffect(() => { + plcRegister() + .then((result) => { + setRegisteredDid(result.did) + }) + .catch((e) => { + setRegError(e instanceof Error ? e.message : "Registration failed. Check your backend connection and try again.") + }) + .finally(() => setRegistering(false)) + }, []) + + const handleDownloadKey = async () => { + setDownloading(true) + setDownloadError(null) + try { + const res = await fetch("/api/setup/rotation-key") + if (!res.ok) throw new Error("Server returned an error") + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + const disposition = res.headers.get("Content-Disposition") + const filenameMatch = disposition?.match(/filename="?([^";\s]+)"?/) + a.download = filenameMatch?.[1] ?? "rotation-key.json" + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + setKeyDownloaded(true) + } catch { + setDownloadError("Failed to download the rotation key. Check your connection and try again.") + } finally { + setDownloading(false) + } + } + + const handleConfirm = async () => { + setLoading(true) + setError(null) + try { await completeSetup(); onComplete() } + catch (e) { setError(e instanceof Error ? e.message : "Failed to complete setup. Check your backend connection and try again.") } + finally { setLoading(false) } + } + + if (registering) { + return ( + + Registering your identity… + +

Creating your identity in the AT Protocol directory. This usually takes a few seconds.

+
+
+ ) + } + + if (regError) { + return ( + + Registration failed + +

{regError}

+ {onBack && ( + + )} +
+
+ ) + } + + return ( + + + Save your rotation key + Your identity is registered. Download the rotation key now — you won't be able to access it again after this step. Learn more + + +
+ {registeredDid && ( +
+
Identity
+
{registeredDid}
+
+ )} +
+
Signing key
+
P-256 keypair, encrypted at rest
+
+
+
Rotation key
+
Generated separately — download it below
+
+
+
+

If you lose this key and this HappyView instance goes down, you won't be able to recover or update your identity.

+
+ + {downloadError &&

{downloadError}

} + {!keyDownloaded ? ( +

You must download the rotation key before continuing.

+ ) : ( +

Store this file somewhere safe and offline — a password manager, encrypted USB drive, or secure backup.

+ )} + {error &&

{error}

} +
+ {onBack ? ( + + ) :
} + +
+ + + ) +} + +// ─── Root dispatcher ───────────────────────────────────────────────────────── + +export function SetupVerify({ mode, onComplete, onBack }: SetupVerifyProps) { + if (mode === "did_web") return + if (mode === "attach_account") return + if (mode === "did_plc") return + return null +} diff --git a/web/src/components/setup/setup-wizard.tsx b/web/src/components/setup/setup-wizard.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/setup/setup-wizard.tsx @@ -0,0 +1,216 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { getSetupStatus, setSetupIdentity } from "@/lib/api" +import { SetupIdentityMode } from "./setup-identity-mode" +import { SetupConfigure } from "./setup-configure" +import { SetupAttachAuth } from "./setup-attach-auth" +import { SetupVerify } from "./setup-verify" +import { SetupComplete } from "./setup-complete" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { + Stepper, StepperItem, StepperList, + StepperIndicator, StepperSeparator, StepperTitle, StepperTrigger, +} from "@/components/ui/stepper" + +type SetupStep = "mode" | "configure" | "attach-auth" | "verify" | "complete" + +export function SetupWizard() { + const [currentStep, setCurrentStep] = useState("mode") + const [identityMode, setIdentityMode] = useState(null) + const [attachedDid, setAttachedDid] = useState(null) + const [attachedHandle, setAttachedHandle] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const lastFailedModeRef = useRef(null) + const stepContentRef = useRef(null) + + const initialLoadRef = useRef(true) + useEffect(() => { + if (initialLoadRef.current) { + initialLoadRef.current = false + return + } + stepContentRef.current?.focus() + }, [currentStep]) + + useEffect(() => { + getSetupStatus() + .then((status) => { + if (status.setup_complete) { + setCurrentStep("complete") + } else if (status.plc_verified) { + setCurrentStep("complete") + } else if (status.identity_configured) { + setIdentityMode(status.identity_mode) + setCurrentStep("verify") + } else if (status.identity_mode && status.identity_mode !== "not_exposed") { + setIdentityMode(status.identity_mode) + + const pendingAuth = localStorage.getItem("happyview_attach_auth") + if (status.identity_mode === "attach_account" && pendingAuth) { + try { + const payload = JSON.parse(pendingAuth) as { attachedDid: string } + setAttachedDid(payload.attachedDid) + setCurrentStep("attach-auth") + } catch { + setCurrentStep("configure") + } + } else { + setCurrentStep("configure") + } + } + }) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + const handleModeSelected = useCallback(async (mode: string) => { + setIdentityMode(mode) + setError(null) + lastFailedModeRef.current = null + if (mode === "not_exposed") { + try { + await setSetupIdentity({ mode: "not_exposed" }) + setCurrentStep("complete") + } catch (e) { + lastFailedModeRef.current = mode + setError(e instanceof Error ? e.message : "Failed to save configuration. Check that your backend is running and try again.") + } + } else if (mode === "attach_account") { + setCurrentStep("configure") + } else { + try { + await setSetupIdentity({ mode }) + setCurrentStep("verify") + } catch (e) { + lastFailedModeRef.current = mode + setError(e instanceof Error ? e.message : "Failed to configure identity. Check that your backend is running and try again.") + } + } + }, []) + + const handleGoBack = useCallback(() => { + setError(null) + switch (currentStep) { + case "configure": + setCurrentStep("mode") + break + case "attach-auth": + setCurrentStep("configure") + break + case "verify": + setCurrentStep(identityMode === "attach_account" ? "configure" : "mode") + break + } + }, [currentStep, identityMode]) + + const handleConfigureComplete = useCallback((opts?: { attachedDid?: string; attachedHandle?: string | null }) => { + if (opts?.attachedDid) { + setAttachedDid(opts.attachedDid) + setAttachedHandle(opts.attachedHandle ?? null) + setCurrentStep("attach-auth") + } else { + setCurrentStep("verify") + } + }, []) + + const handleAttachAuthComplete = useCallback(() => { + setCurrentStep("verify") + }, []) + + const handleVerifyComplete = useCallback(() => { + setCurrentStep("complete") + }, []) + + const stepOrder = useMemo(() => identityMode === "attach_account" + ? ["mode", "configure", "attach-auth", "verify", "complete"] + : ["mode", "verify", "complete"] + , [identityMode]) + + const handleStepperNav = useCallback((value: string) => { + const target = value as SetupStep + const currentIndex = stepOrder.indexOf(currentStep) + const targetIndex = stepOrder.indexOf(target) + if (targetIndex < 0 || targetIndex > currentIndex) return + if (target === "mode") { + setIdentityMode(null) + setAttachedDid(null) + setAttachedHandle(null) + setError(null) + } + setCurrentStep(target) + }, [currentStep, stepOrder]) + + if (loading) { + return ( +
+ + +
+ ) + } + + return ( + + + + Identity + + + {identityMode === "attach_account" && ( + <> + + Account + + + + Sign In + + + + )} + + {identityMode === "did_plc" ? "Key Backup" : identityMode === "attach_account" ? "Verify" : "Review"} + + + + Done + + + + {error && ( +
+ {error} + {lastFailedModeRef.current && ( + + )} +
+ )} + +
+ {currentStep === "mode" && } + {currentStep === "configure" && identityMode && ( + + )} + {currentStep === "attach-auth" && attachedDid && ( + + )} + {currentStep === "verify" && identityMode && ( + + )} + {currentStep === "complete" && } +
+
+ ) +} diff --git a/web/src/components/ui/stepper.tsx b/web/src/components/ui/stepper.tsx new file mode 100644 --- /dev/null +++ b/web/src/components/ui/stepper.tsx @@ -0,0 +1,1277 @@ +"use client"; + +import { Check } from "lucide-react"; +import { + Direction as DirectionPrimitive, + Slot as SlotPrimitive, +} from "radix-ui"; +import * as React from "react"; +import { useComposedRefs } from "@/lib/compose-refs"; +import { cn } from "@/lib/utils"; +import { useAsRef } from "@/hooks/use-as-ref"; +import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect"; +import { useLazyRef } from "@/hooks/use-lazy-ref"; + +const ROOT_NAME = "Stepper"; +const LIST_NAME = "StepperList"; +const ITEM_NAME = "StepperItem"; +const TRIGGER_NAME = "StepperTrigger"; +const INDICATOR_NAME = "StepperIndicator"; +const SEPARATOR_NAME = "StepperSeparator"; +const TITLE_NAME = "StepperTitle"; +const DESCRIPTION_NAME = "StepperDescription"; +const CONTENT_NAME = "StepperContent"; +const PREV_NAME = "StepperPrev"; +const NEXT_NAME = "StepperNext"; + +const ENTRY_FOCUS = "stepperFocusGroup.onEntryFocus"; +const EVENT_OPTIONS = { bubbles: false, cancelable: true }; +const ARROW_KEYS = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; + +type Direction = "ltr" | "rtl"; +type Orientation = "horizontal" | "vertical"; +type NavigationDirection = "next" | "prev"; +type ActivationMode = "automatic" | "manual"; +type DataState = "inactive" | "active" | "completed"; + +interface DivProps extends React.ComponentProps<"div"> { + asChild?: boolean; +} +interface ButtonProps extends React.ComponentProps<"button"> { + asChild?: boolean; +} + +type ListElement = React.ComponentRef; +type TriggerElement = React.ComponentRef; + +function getId( + id: string, + variant: "trigger" | "content" | "title" | "description", + value: string, +) { + return `${id}-${variant}-${value}`; +} + +type FocusIntent = "first" | "last" | "prev" | "next"; + +const MAP_KEY_TO_FOCUS_INTENT: Record = { + ArrowLeft: "prev", + ArrowUp: "prev", + ArrowRight: "next", + ArrowDown: "next", + PageUp: "first", + Home: "first", + PageDown: "last", + End: "last", +}; + +function getDirectionAwareKey(key: string, dir?: Direction) { + if (dir !== "rtl") return key; + return key === "ArrowLeft" + ? "ArrowRight" + : key === "ArrowRight" + ? "ArrowLeft" + : key; +} + +function getFocusIntent( + event: React.KeyboardEvent, + dir?: Direction, + orientation?: Orientation, +) { + const key = getDirectionAwareKey(event.key, dir); + if (orientation === "horizontal" && ["ArrowUp", "ArrowDown"].includes(key)) + return undefined; + if (orientation === "vertical" && ["ArrowLeft", "ArrowRight"].includes(key)) + return undefined; + return MAP_KEY_TO_FOCUS_INTENT[key]; +} + +function focusFirst( + candidates: React.RefObject[], + preventScroll = false, +) { + const PREVIOUSLY_FOCUSED_ELEMENT = document.activeElement; + for (const candidateRef of candidates) { + const candidate = candidateRef.current; + if (!candidate) continue; + if (candidate === PREVIOUSLY_FOCUSED_ELEMENT) return; + candidate.focus({ preventScroll }); + if (document.activeElement !== PREVIOUSLY_FOCUSED_ELEMENT) return; + } +} + +function wrapArray(array: T[], startIndex: number) { + return array.map( + (_, index) => array[(startIndex + index) % array.length] as T, + ); +} + +function getDataState( + value: string | undefined, + itemValue: string, + stepState: StepState | undefined, + steps: Map, + variant: "item" | "separator" = "item", +): DataState { + const stepKeys = Array.from(steps.keys()); + const currentIndex = stepKeys.indexOf(itemValue); + + if (stepState?.completed) return "completed"; + + if (value === itemValue) { + return variant === "separator" ? "inactive" : "active"; + } + + if (value) { + const activeIndex = stepKeys.indexOf(value); + + if (activeIndex > currentIndex) return "completed"; + } + + return "inactive"; +} + +interface StepState { + value: string; + completed: boolean; + disabled: boolean; +} + +interface StoreState { + steps: Map; + value: string; +} + +interface Store { + subscribe: (callback: () => void) => () => void; + getState: () => StoreState; + setState: (key: K, value: StoreState[K]) => void; + setStateWithValidation: ( + value: string, + direction: NavigationDirection, + ) => Promise; + hasValidation: () => boolean; + notify: () => void; + addStep: (value: string, completed: boolean, disabled: boolean) => void; + removeStep: (value: string) => void; + setStep: (value: string, completed: boolean, disabled: boolean) => void; +} + +const StoreContext = React.createContext(null); + +function useStoreContext(consumerName: string) { + const context = React.useContext(StoreContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +function useStore(selector: (state: StoreState) => T): T { + const store = useStoreContext("useStore"); + + const getSnapshot = React.useCallback( + () => selector(store.getState()), + [store, selector], + ); + + return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot); +} + +interface ItemData { + id: string; + ref: React.RefObject; + value: string; + active: boolean; + disabled: boolean; +} + +interface StepperContextValue { + rootId: string; + dir: Direction; + orientation: Orientation; + activationMode: ActivationMode; + disabled: boolean; + nonInteractive: boolean; + loop: boolean; +} + +const StepperContext = React.createContext(null); + +function useStepperContext(consumerName: string) { + const context = React.useContext(StepperContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``); + } + return context; +} + +interface StepperProps extends DivProps { + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + onValueComplete?: (value: string, completed: boolean) => void; + onValueAdd?: (value: string) => void; + onValueRemove?: (value: string) => void; + onValidate?: ( + value: string, + direction: NavigationDirection, + ) => boolean | Promise; + activationMode?: ActivationMode; + dir?: Direction; + orientation?: Orientation; + disabled?: boolean; + loop?: boolean; + nonInteractive?: boolean; +} + +function Stepper(props: StepperProps) { + const { + value, + defaultValue, + onValueChange, + onValueComplete, + onValueAdd, + onValueRemove, + onValidate, + dir: dirProp, + orientation = "horizontal", + activationMode = "automatic", + asChild, + disabled = false, + nonInteractive = false, + loop = false, + className, + id, + ...rootProps + } = props; + + const listenersRef = useLazyRef(() => new Set<() => void>()); + const stateRef = useLazyRef(() => ({ + steps: new Map(), + value: value ?? defaultValue ?? "", + })); + + const propsRef = useAsRef({ + onValueChange, + onValueComplete, + onValueAdd, + onValueRemove, + onValidate, + }); + + const store = React.useMemo(() => { + return { + subscribe: (cb) => { + listenersRef.current.add(cb); + return () => listenersRef.current.delete(cb); + }, + getState: () => stateRef.current, + setState: (key, value) => { + if (Object.is(stateRef.current[key], value)) return; + + if (key === "value" && typeof value === "string") { + stateRef.current.value = value; + propsRef.current.onValueChange?.(value); + } else { + stateRef.current[key] = value; + } + + store.notify(); + }, + setStateWithValidation: async (value, direction) => { + if (!propsRef.current.onValidate) { + store.setState("value", value); + return true; + } + + try { + const isValid = await propsRef.current.onValidate(value, direction); + if (isValid) { + store.setState("value", value); + } + return isValid; + } catch { + return false; + } + }, + hasValidation: () => !!propsRef.current.onValidate, + addStep: (value, completed, disabled) => { + const newStep: StepState = { value, completed, disabled }; + stateRef.current.steps.set(value, newStep); + propsRef.current.onValueAdd?.(value); + store.notify(); + }, + removeStep: (value) => { + stateRef.current.steps.delete(value); + propsRef.current.onValueRemove?.(value); + store.notify(); + }, + setStep: (value, completed, disabled) => { + const step = stateRef.current.steps.get(value); + if (step) { + const updatedStep: StepState = { ...step, completed, disabled }; + stateRef.current.steps.set(value, updatedStep); + + if (completed !== step.completed) { + propsRef.current.onValueComplete?.(value, completed); + } + + store.notify(); + } + }, + notify: () => { + for (const cb of listenersRef.current) { + cb(); + } + }, + }; + }, [listenersRef, stateRef, propsRef]); + + useIsomorphicLayoutEffect(() => { + if (value !== undefined) { + store.setState("value", value); + } + }, [value]); + + const dir = DirectionPrimitive.useDirection(dirProp); + + const instanceId = React.useId(); + const rootId = id ?? instanceId; + + const contextValue = React.useMemo( + () => ({ + rootId, + dir, + orientation, + activationMode, + disabled, + nonInteractive, + loop, + }), + [rootId, dir, orientation, activationMode, disabled, nonInteractive, loop], + ); + + const RootPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( + + + + + + ); +} + +interface FocusContextValue { + tabStopId: string | null; + onItemFocus: (tabStopId: string) => void; + onItemShiftTab: () => void; + onFocusableItemAdd: () => void; + onFocusableItemRemove: () => void; + onItemRegister: (item: ItemData) => void; + onItemUnregister: (id: string) => void; + getItems: () => ItemData[]; +} + +const FocusContext = React.createContext(null); + +function useFocusContext(consumerName: string) { + const context = React.useContext(FocusContext); + if (!context) { + throw new Error( + `\`${consumerName}\` must be used within \`FocusProvider\``, + ); + } + return context; +} + +function StepperList(props: DivProps) { + const { + asChild, + onBlur: onBlurProp, + onFocus: onFocusProp, + onMouseDown: onMouseDownProp, + className, + children, + ref, + ...listProps + } = props; + + const context = useStepperContext(LIST_NAME); + const orientation = context.orientation; + const currentValue = useStore((state) => state.value); + + const propsRef = useAsRef({ + onBlur: onBlurProp, + onFocus: onFocusProp, + onMouseDown: onMouseDownProp, + }); + + const [tabStopId, setTabStopId] = React.useState(null); + const [isTabbingBackOut, setIsTabbingBackOut] = React.useState(false); + const [focusableItemCount, setFocusableItemCount] = React.useState(0); + const isClickFocusRef = React.useRef(false); + const itemsRef = React.useRef>(new Map()); + const listRef = React.useRef(null); + const composedRef = useComposedRefs(ref, listRef); + + const onItemFocus = React.useCallback((tabStopId: string) => { + setTabStopId(tabStopId); + }, []); + + const onItemShiftTab = React.useCallback(() => { + setIsTabbingBackOut(true); + }, []); + + const onFocusableItemAdd = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount + 1); + }, []); + + const onFocusableItemRemove = React.useCallback(() => { + setFocusableItemCount((prevCount) => prevCount - 1); + }, []); + + const onItemRegister = React.useCallback((item: ItemData) => { + itemsRef.current.set(item.id, item); + }, []); + + const onItemUnregister = React.useCallback((id: string) => { + itemsRef.current.delete(id); + }, []); + + const getItems = React.useCallback(() => { + return Array.from(itemsRef.current.values()) + .filter((item) => item.ref.current) + .sort((a, b) => { + const elementA = a.ref.current; + const elementB = b.ref.current; + if (!elementA || !elementB) return 0; + const position = elementA.compareDocumentPosition(elementB); + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } + if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + return 0; + }); + }, []); + + const onBlur = React.useCallback( + (event: React.FocusEvent) => { + propsRef.current.onBlur?.(event); + if (event.defaultPrevented) return; + + setIsTabbingBackOut(false); + }, + [propsRef], + ); + + const onFocus = React.useCallback( + (event: React.FocusEvent) => { + propsRef.current.onFocus?.(event); + if (event.defaultPrevented) return; + + const isKeyboardFocus = !isClickFocusRef.current; + if ( + event.target === event.currentTarget && + isKeyboardFocus && + !isTabbingBackOut + ) { + const entryFocusEvent = new CustomEvent(ENTRY_FOCUS, EVENT_OPTIONS); + event.currentTarget.dispatchEvent(entryFocusEvent); + + if (!entryFocusEvent.defaultPrevented) { + const items = Array.from(itemsRef.current.values()).filter( + (item) => !item.disabled, + ); + const selectedItem = currentValue + ? items.find((item) => item.value === currentValue) + : undefined; + const activeItem = items.find((item) => item.active); + const currentItem = items.find((item) => item.id === tabStopId); + + const candidateItems = [ + selectedItem, + activeItem, + currentItem, + ...items, + ].filter(Boolean) as ItemData[]; + const candidateRefs = candidateItems.map((item) => item.ref); + focusFirst(candidateRefs, false); + } + } + isClickFocusRef.current = false; + }, + [propsRef, isTabbingBackOut, currentValue, tabStopId], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseDown?.(event); + + if (event.defaultPrevented) return; + + isClickFocusRef.current = true; + }, + [propsRef], + ); + + const focusContextValue = React.useMemo( + () => ({ + tabStopId, + onItemFocus, + onItemShiftTab, + onFocusableItemAdd, + onFocusableItemRemove, + onItemRegister, + onItemUnregister, + getItems, + }), + [ + tabStopId, + onItemFocus, + onItemShiftTab, + onFocusableItemAdd, + onFocusableItemRemove, + onItemRegister, + onItemUnregister, + getItems, + ], + ); + + const ListPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( + + + {children} + + + ); +} + +interface StepperItemContextValue { + value: string; + stepState: StepState | undefined; +} + +const StepperItemContext = React.createContext( + null, +); + +function useStepperItemContext(consumerName: string) { + const context = React.useContext(StepperItemContext); + if (!context) { + throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``); + } + return context; +} + +interface StepperItemProps extends DivProps { + value: string; + completed?: boolean; + disabled?: boolean; +} + +function StepperItem(props: StepperItemProps) { + const { + value: itemValue, + completed = false, + disabled = false, + asChild, + className, + children, + ref, + ...itemProps + } = props; + + const context = useStepperContext(ITEM_NAME); + const store = useStoreContext(ITEM_NAME); + const orientation = context.orientation; + const value = useStore((state) => state.value); + + useIsomorphicLayoutEffect(() => { + store.addStep(itemValue, completed, disabled); + + return () => { + store.removeStep(itemValue); + }; + }, [itemValue, completed, disabled]); + + useIsomorphicLayoutEffect(() => { + store.setStep(itemValue, completed, disabled); + }, [itemValue, completed, disabled]); + + const stepState = useStore((state) => state.steps.get(itemValue)); + const steps = useStore((state) => state.steps); + const dataState = getDataState(value, itemValue, stepState, steps); + + const itemContextValue = React.useMemo( + () => ({ + value: itemValue, + stepState, + }), + [itemValue, stepState], + ); + + const ItemPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( + + + {children} + + + ); +} + +function StepperTrigger(props: ButtonProps) { + const { + asChild, + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + disabled, + className, + ref, + ...triggerProps + } = props; + + const context = useStepperContext(TRIGGER_NAME); + const itemContext = useStepperItemContext(TRIGGER_NAME); + const itemValue = itemContext.value; + + const store = useStoreContext(TRIGGER_NAME); + const focusContext = useFocusContext(TRIGGER_NAME); + const value = useStore((state) => state.value); + const steps = useStore((state) => state.steps); + const stepState = useStore((state) => state.steps.get(itemValue)); + + const propsRef = useAsRef({ + onClick: onClickProp, + onFocus: onFocusProp, + onKeyDown: onKeyDownProp, + onMouseDown: onMouseDownProp, + }); + + const activationMode = context.activationMode; + const orientation = context.orientation; + const loop = context.loop; + + const stepIndex = Array.from(steps.keys()).indexOf(itemValue); + + const stepPosition = stepIndex + 1; + const stepCount = steps.size; + + const triggerId = getId(context.rootId, "trigger", itemValue); + const contentId = getId(context.rootId, "content", itemValue); + const titleId = getId(context.rootId, "title", itemValue); + const descriptionId = getId(context.rootId, "description", itemValue); + + const isDisabled = disabled || stepState?.disabled || context.disabled; + const isActive = value === itemValue; + const isTabStop = focusContext.tabStopId === triggerId; + const dataState = getDataState(value, itemValue, stepState, steps); + + const triggerRef = React.useRef(null); + const composedRef = useComposedRefs(ref, triggerRef); + const isArrowKeyPressedRef = React.useRef(false); + const isMouseClickRef = React.useRef(false); + + React.useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (ARROW_KEYS.includes(event.key)) { + isArrowKeyPressedRef.current = true; + } + } + function onKeyUp() { + isArrowKeyPressedRef.current = false; + } + document.addEventListener("keydown", onKeyDown); + document.addEventListener("keyup", onKeyUp); + return () => { + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keyup", onKeyUp); + }; + }, []); + + useIsomorphicLayoutEffect(() => { + focusContext.onItemRegister({ + id: triggerId, + ref: triggerRef, + value: itemValue, + active: isTabStop, + disabled: !!isDisabled, + }); + + if (!isDisabled) { + focusContext.onFocusableItemAdd(); + } + + return () => { + focusContext.onItemUnregister(triggerId); + if (!isDisabled) { + focusContext.onFocusableItemRemove(); + } + }; + }, [focusContext, triggerId, itemValue, isTabStop, isDisabled]); + + const onClick = React.useCallback( + async (event: React.MouseEvent) => { + propsRef.current.onClick?.(event); + if (event.defaultPrevented) return; + + if (!isDisabled && !context.nonInteractive) { + const currentStepIndex = Array.from(steps.keys()).indexOf(value ?? ""); + const targetStepIndex = Array.from(steps.keys()).indexOf(itemValue); + const direction = targetStepIndex > currentStepIndex ? "next" : "prev"; + + await store.setStateWithValidation(itemValue, direction); + } + }, + [ + isDisabled, + context.nonInteractive, + store, + itemValue, + value, + steps, + propsRef, + ], + ); + + const onFocus = React.useCallback( + async (event: React.FocusEvent) => { + propsRef.current.onFocus?.(event); + if (event.defaultPrevented) return; + + focusContext.onItemFocus(triggerId); + + const isKeyboardFocus = !isMouseClickRef.current; + + if ( + !isActive && + !isDisabled && + activationMode !== "manual" && + !context.nonInteractive && + isKeyboardFocus + ) { + const currentStepIndex = Array.from(steps.keys()).indexOf(value || ""); + const targetStepIndex = Array.from(steps.keys()).indexOf(itemValue); + const direction = targetStepIndex > currentStepIndex ? "next" : "prev"; + + await store.setStateWithValidation(itemValue, direction); + } + + isMouseClickRef.current = false; + }, + [ + focusContext, + triggerId, + activationMode, + isActive, + isDisabled, + context.nonInteractive, + store, + itemValue, + value, + steps, + propsRef, + ], + ); + + const onKeyDown = React.useCallback( + async (event: React.KeyboardEvent) => { + propsRef.current.onKeyDown?.(event); + if (event.defaultPrevented) return; + + if (event.key === "Enter" && context.nonInteractive) { + event.preventDefault(); + return; + } + + if ( + (event.key === "Enter" || event.key === " ") && + activationMode === "manual" && + !context.nonInteractive + ) { + event.preventDefault(); + if (!isDisabled && triggerRef.current) { + triggerRef.current.click(); + } + return; + } + + if (event.key === "Tab" && event.shiftKey) { + focusContext.onItemShiftTab(); + return; + } + + if (event.target !== event.currentTarget) return; + + const focusIntent = getFocusIntent(event, context.dir, orientation); + + if (focusIntent !== undefined) { + if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) + return; + event.preventDefault(); + + const items = focusContext.getItems().filter((item) => !item.disabled); + let candidateRefs = items.map((item) => item.ref); + + if (focusIntent === "last") { + candidateRefs.reverse(); + } else if (focusIntent === "prev" || focusIntent === "next") { + if (focusIntent === "prev") candidateRefs.reverse(); + const currentIndex = candidateRefs.findIndex( + (ref) => ref.current === event.currentTarget, + ); + candidateRefs = loop + ? wrapArray(candidateRefs, currentIndex + 1) + : candidateRefs.slice(currentIndex + 1); + } + + if (store.hasValidation() && candidateRefs.length > 0) { + const nextRef = candidateRefs[0]; + const nextElement = nextRef?.current; + const nextItem = items.find( + (item) => item.ref.current === nextElement, + ); + + if (nextItem && nextItem.value !== itemValue) { + const currentStepIndex = Array.from(steps.keys()).indexOf( + value || "", + ); + const targetStepIndex = Array.from(steps.keys()).indexOf( + nextItem.value, + ); + const direction: NavigationDirection = + targetStepIndex > currentStepIndex ? "next" : "prev"; + + if (direction === "next") { + const isValid = await store.setStateWithValidation( + nextItem.value, + direction, + ); + if (!isValid) return; + } else { + store.setState("value", nextItem.value); + } + + queueMicrotask(() => nextElement?.focus()); + return; + } + } + + queueMicrotask(() => focusFirst(candidateRefs)); + } + }, + [ + focusContext, + context.nonInteractive, + context.dir, + activationMode, + orientation, + loop, + isDisabled, + store, + propsRef, + itemValue, + value, + steps, + ], + ); + + const onMouseDown = React.useCallback( + (event: React.MouseEvent) => { + propsRef.current.onMouseDown?.(event); + if (event.defaultPrevented) return; + + isMouseClickRef.current = true; + + if (isDisabled) { + event.preventDefault(); + } else { + focusContext.onItemFocus(triggerId); + } + }, + [focusContext, triggerId, isDisabled, propsRef], + ); + + const TriggerPrimitive = asChild ? SlotPrimitive.Slot : "button"; + + return ( + + ); +} + +interface StepperIndicatorProps extends Omit { + children?: React.ReactNode | ((dataState: DataState) => React.ReactNode); +} + +function StepperIndicator(props: StepperIndicatorProps) { + const { className, children, asChild, ref, ...indicatorProps } = props; + + const context = useStepperContext(INDICATOR_NAME); + const itemContext = useStepperItemContext(INDICATOR_NAME); + + const value = useStore((state) => state.value); + const itemValue = itemContext.value; + const stepState = useStore((state) => state.steps.get(itemValue)); + const steps = useStore((state) => state.steps); + + const stepPosition = Array.from(steps.keys()).indexOf(itemValue) + 1; + + const dataState = getDataState(value, itemValue, stepState, steps); + + const IndicatorPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( + + {typeof children === "function" ? ( + children(dataState) + ) : children ? ( + children + ) : dataState === "completed" ? ( + + ) : ( + stepPosition + )} + + ); +} + +interface StepperSeparatorProps extends DivProps { + forceMount?: boolean; +} + +function StepperSeparator(props: StepperSeparatorProps) { + const { + className, + asChild, + forceMount = false, + ref, + ...separatorProps + } = props; + + const context = useStepperContext(SEPARATOR_NAME); + const itemContext = useStepperItemContext(SEPARATOR_NAME); + const value = useStore((state) => state.value); + const steps = useStore((state) => state.steps); + + const orientation = context.orientation; + + const stepIndex = Array.from(steps.keys()).indexOf(itemContext.value); + + const isLastStep = stepIndex === steps.size - 1; + + if (isLastStep && !forceMount) return null; + + const dataState = getDataState( + value, + itemContext.value, + itemContext.stepState, + steps, + "separator", + ); + + const SeparatorPrimitive = asChild ? SlotPrimitive.Slot : "div"; + + return ( +