diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,49 @@ - name: Clippy run: cargo clippy --all-targets -- -D warnings + playwright: + needs: [changes, frontend] + if: always() && (needs.changes.outputs.server == 'true' || (github.event_name == 'workflow_dispatch' && inputs.server)) && needs.frontend.result == 'success' + runs-on: depot-ubuntu-24.04 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + + - name: Install frontend dependencies + working-directory: web + run: npm ci + + - name: Install Playwright browsers + working-directory: web + run: npx playwright install chromium --with-deps + + - name: Start E2E services + run: docker compose -f docker-compose.e2e.yml up -d --build --wait + timeout-minutes: 10 + + - name: Run Playwright tests + working-directory: web + run: npx playwright test + env: + PLAYWRIGHT_BASE_URL: http://localhost:3200 + + - name: Upload Playwright report + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: web/playwright-report/ + retention-days: 14 + + - name: Stop E2E services + if: always() + run: docker compose -f docker-compose.e2e.yml down -v + # --------------------------------------------------------------------------- # PR builds — compile + Docker on every PR push so reviewers can test # --------------------------------------------------------------------------- @@ -357,8 +400,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.yml b/docker-compose.e2e.yml new file mode 100644 --- /dev/null +++ b/docker-compose.e2e.yml @@ -0,0 +1,70 @@ +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 + + happyview: + build: + context: . + dockerfile: Dockerfile + environment: + DATABASE_URL: postgres://happyview:happyview@postgres:5432/happyview_test + PUBLIC_URL: http://localhost: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: 0000000000000000000000000000000000000000000000000000000000000042 + JETSTREAM_URL: wss://jetstream1.us-east.bsky.network + ports: + - "3200:3000" + depends_on: + postgres: + condition: service_healthy + plc: + condition: service_started 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 DEFAULT NOW(), + updated_at TEXT NOT NULL DEFAULT NOW() +); 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 DEFAULT NOW(), + updated_at TEXT NOT NULL DEFAULT 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/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/scripts/e2e-config.toml b/scripts/e2e-config.toml new file mode 100644 --- /dev/null +++ b/scripts/e2e-config.toml @@ -0,0 +1,17 @@ +[server] +hostname = "localhost" +allow_http_proxy = true +invite_code_required = false +disable_rate_limiting = 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/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/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,479 @@ +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; + +/// 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?; + let agent = Agent::new(session); + + agent + .api + .com + .atproto + .identity + .request_plc_operation_signature() + .await + .map_err(|e| 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?; + 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| 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| 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,30 @@ 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 + if let Ok(service_claims) = try_parse_service_auth(token, state).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 +314,61 @@ // 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, +) -> 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 = identity + .did + .as_ref() + .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/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,41 @@ } 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()); + } +} 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}"); @@ -263,7 +241,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,125 @@ +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()); + +pub fn extract_outbound_xrpcs(source: &str) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut result = Vec::new(); + + for line in source.lines() { + // Skip lines whose non-whitespace content starts with a Lua comment (`--`). + 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",] + ); + } +} 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,311 @@ +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")); + } +} 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,74 @@ 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, +) -> 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 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 = &state.config.public_url; + + let signing_key_multibase = extract_public_key_multibase(&identity, &state)?; + + let doc = crate::service_identity::generate_did_document( + &identity, + &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,310 @@ +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 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, +} + +// Row type: (mode, did, signing_key_enc, setup_complete, created_at, updated_at) +type ServiceIdentityRow = (String, Option, Option, bool, 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, + setup_complete: r.3, + created_at: r.4, + updated_at: r.5, + }) +} + +/// 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, setup_complete, 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 = 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, ?, ?, ?, ?, ?, ?, ?, ?) + 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(false) + .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 = ?, updated_at = ? WHERE id = 1", + backend, + ); + + sqlx::query(&sql) + .bind(true) + .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. +/// Returns None if the identity mode is not DidWeb or if required fields are missing. +pub fn generate_did_document( + identity: &ServiceIdentity, + signing_key_multibase: &str, + service_entries: &[(String, String)], + service_endpoint: &str, +) -> Option { + if identity.mode != IdentityMode::DidWeb { + return None; + } + + let did = identity.did.as_deref()?; + + let verification_method = serde_json::json!([{ + "id": format!("{}#atproto", did), + "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, + 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, "zKey", &[], "https://example.com").is_none()); + } + + #[test] + fn generate_did_document_returns_none_without_did() { + let identity = make_identity(IdentityMode::DidWeb, None); + assert!(generate_did_document(&identity, "zKey", &[], "https://example.com").is_none()); + } + + #[test] + fn generate_did_document_with_no_entries() { + let identity = make_identity(IdentityMode::DidWeb, Some("did:web:example.com")); + let doc = generate_did_document(&identity, "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, Some("did:web:example.com")); + let entries = vec![ + ("#chess".to_string(), "ChessService".to_string()), + ("#checkers".to_string(), "CheckersService".to_string()), + ]; + let doc = + generate_did_document(&identity, "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, Some("did:web:example.com")); + let doc = generate_did_document(&identity, "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,677 @@ +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::event_log::{EventLog, Severity, log_event}; +use crate::service_identity::{self, IdentityMode}; +use crate::{AppState, error::AppError}; + +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( + 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( + State(state): State, + _auth: UserAuth, + Json(body): Json, +) -> Result { + 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 => { + // Derive domain from public_url: strip https:// prefix and trailing slash + let domain = state + .config + .public_url + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/'); + let did = format!("did:web:{domain}"); + + let signing_key_enc = generate_encrypted_signing_key(&state)?; + + (Some(did), 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( + State(state): State, + _auth: UserAuth, +) -> Result, AppError> { + 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( + State(state): State, + _auth: UserAuth, +) -> Result { + 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?; + 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| AppError::Internal(format!("requestPlcOperationSignature failed: {e}")))?; + + Ok(StatusCode::NO_CONTENT) +} + +async fn plc_submit( + State(state): State, + _auth: UserAuth, + Json(body): Json, +) -> Result { + 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?; + 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| 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| 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 mut session_cookie = Cookie::new(COOKIE_NAME, original_did); + 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); + + let jar = jar.add(session_cookie); + + Ok((jar, StatusCode::NO_CONTENT)) +} + +async fn export_rotation_key( + State(state): State, + _auth: UserAuth, +) -> Result { + 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(State(state): State, auth: UserAuth) -> Result { + 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: Some(auth.did.clone()), + 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( + 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,7 @@ AtprotoLocalhostClientMetadata, DefaultHttpClient, KnownScope, OAuthClientConfig, OAuthResolverConfig, Scope, }; use axum::Router; +use base64::Engine as _; use happyview::config::Config; use happyview::db::{DatabaseBackend, adapt_sql, now_rfc3339}; use happyview::lexicon::LexiconRegistry; @@ -20,6 +21,7 @@ pub state: AppState, pub mock_server: MockServer, pub admin_did: String, pub admin_token: String, + _db_lock: Option, } impl TestApp { @@ -33,6 +35,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 +175,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 +196,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 +272,305 @@ /// 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) + } + + 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, + Some(&did), + 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,360 @@ +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() +} + +// --------------------------------------------------------------------------- +// 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); +} 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,202 @@ +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; + let did = 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_eq!(body["did"], did); + 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_service_identity.rs b/tests/e2e_service_identity.rs new file mode 100644 --- /dev/null +++ b/tests/e2e_service_identity.rs @@ -0,0 +1,1590 @@ +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( + Request::builder() + .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( + Request::builder() + .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( + Request::builder() + .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") + .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") + .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") + .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) + .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) + .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) + .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("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("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("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) + .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) + .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) + .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("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 cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header(cookie.0.clone(), cookie.1.clone()) + .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( + Request::builder() + .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(); + + let cookie = app.admin_cookie(); + + // Step 1: POST /api/setup/identity with mode=did_web + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header(cookie.0.clone(), cookie.1.clone()) + .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( + Request::builder() + .method("POST") + .uri("/api/setup/complete") + .header(cookie.0.clone(), cookie.1.clone()) + .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") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let doc = json_body(resp).await; + assert!(doc["id"].as_str().unwrap().starts_with("did:web:")); + assert!(!doc["verificationMethod"].as_array().unwrap().is_empty()); + + // Step 4: Verify status shows complete + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .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) + .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) + .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" + ); +} + +// --------------------------------------------------------------------------- +// 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" + ); +} 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/package-lock.json b/web/package-lock.json --- a/web/package-lock.json +++ b/web/package-lock.json @@ -37,6 +37,7 @@ "vaul": "^1.1.2", "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.2.0", "@types/node": "^24", "@types/react": "^19", @@ -117,6 +118,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -774,6 +776,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2044,6 +2047,7 @@ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -2152,6 +2156,23 @@ "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", + "peer": true, + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } }, "node_modules/@radix-ui/number": { "version": "1.1.1", @@ -4368,6 +4389,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -4377,6 +4399,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4387,6 +4410,7 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4410,8 +4434,7 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -4477,6 +4500,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -4990,6 +5014,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5376,6 +5401,7 @@ "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -5480,6 +5506,7 @@ "url": "https://github.com/sponsors/ai" } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6441,7 +6468,6 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -6780,6 +6806,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -6920,6 +6947,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -7398,6 +7426,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -7729,6 +7758,21 @@ "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==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -8196,6 +8240,7 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.11.9.tgz", "integrity": "sha512-Eaw2YTGM6WOxA6CXbckaEvslr2Ne4NFsKrvc0v97JD5awbmeBLO5w9Ho9L9kmKonrwF9RJlW6BxT1PVv/agBHQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -9621,7 +9666,6 @@ "version": "14.0.0", "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -11464,6 +11508,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", @@ -11778,6 +11854,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -11808,6 +11885,7 @@ "version": "19.2.4", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -11854,6 +11932,7 @@ "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", + "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -11992,7 +12071,8 @@ "node_modules/redux": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -13187,7 +13267,8 @@ "node_modules/tailwindcss": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.0.tgz", "integrity": "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -13260,6 +13341,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -13537,6 +13619,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14286,6 +14369,7 @@ "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package.json b/web/package.json --- a/web/package.json +++ b/web/package.json @@ -38,6 +38,7 @@ "vaul": "^1.1.2", "zod": "^4.3.6" }, "devDependencies": { + "@playwright/test": "^1.60.0", "@tailwindcss/postcss": "^4.2.0", "@types/node": "^24", "@types/react": "^19", 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,43 @@ +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://localhost: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", + ], + dependencies: ["setup"], + use: { browserName: "chromium" }, + }, + { + name: "attach-account", + testMatch: "setup-attach-account.spec.ts", + dependencies: ["post-setup"], + use: { browserName: "chromium" }, + }, + ], + globalSetup: "./tests/e2e/global-setup.ts", +}) diff --git a/web/src/app/dashboard/layout.tsx b/web/src/app/dashboard/layout.tsx --- a/web/src/app/dashboard/layout.tsx +++ b/web/src/app/dashboard/layout.tsx @@ -1,8 +1,9 @@ "use client" -import { useEffect } from "react" +import { useEffect, useState } from "react" import { useRouter } from "next/navigation" +import { getSetupStatus } from "@/lib/api" import { useAuth } from "@/lib/auth-context" import { useConfig } from "@/lib/config-context" import { AppSidebar } from "@/components/app-sidebar" @@ -19,6 +20,7 @@ }) { const { did } = useAuth() const { app_name } = useConfig() const router = useRouter() + const [setupChecked, setSetupChecked] = useState(false) useEffect(() => { if (!did) { @@ -27,10 +29,27 @@ } }, [did, router]) useEffect(() => { + if (did) { + getSetupStatus() + .then((status) => { + if (!status.setup_complete) { + router.replace("/setup") + } else { + setSetupChecked(true) + } + }) + .catch(() => { + // If status endpoint fails (e.g. no table yet), allow through + setSetupChecked(true) + }) + } + }, [did, router]) + + useEffect(() => { document.title = app_name ? `${app_name} Admin` : "HappyView Admin" }, [app_name]) - if (!did) return null + if (!did || !setupChecked) return null return ( diff --git a/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx b/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx --- a/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx +++ b/web/src/app/dashboard/lexicons/[id]/lexicon-detail.tsx @@ -7,6 +7,7 @@ import { usePathname, useRouter } from "next/navigation"; import { useCurrentUser } from "@/hooks/use-current-user"; import { CodePanels } from "@/components/code-panels"; +import { LexiconServicesSheet } from "@/components/lexicon-services-sheet"; import { deleteLexicon, deleteNetworkLexicon, @@ -37,6 +38,7 @@ const [scripts, setScripts] = useState([]); const [error, setError] = useState(null); const [deleting, setDeleting] = useState(false); const [saving, setSaving] = useState(false); + const [servicesSheetOpen, setServicesSheetOpen] = useState(false); const [jsonText, setJsonText] = useState(""); const [originalJson, setOriginalJson] = useState(""); @@ -251,6 +253,12 @@ /> )} + + {/* Actions */}